@cortexkit/opencode-magic-context 0.36.0 → 0.37.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/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +2 -1
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +10 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/{conflict-warning-hook-dw9fs934.js → conflict-warning-hook-an4fxhwf.js} +4 -4
- package/dist/{derive-budgets-88mxk2w1.js → derive-budgets-0f8xpyw2.js} +3 -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/memory/embedding-openai.d.ts +3 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/project-identity.d.ts +6 -0
- package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
- package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.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/overflow-detection.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/smart-notes/runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/wake-plane.d.ts +21 -0
- package/dist/features/magic-context/smart-notes/wake-plane.d.ts.map +1 -0
- 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 +14 -3
- 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-meta-persisted.d.ts +22 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-primers.d.ts +11 -0
- package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
- package/dist/features/magic-context/storage-schema-helpers.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/features/magic-context/user-memory/storage-user-memory.d.ts +7 -0
- package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
- package/dist/features/magic-context/window-report-ledger.d.ts +70 -0
- package/dist/features/magic-context/window-report-ledger.d.ts.map +1 -0
- 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/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-resolvers.d.ts +4 -0
- package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
- package/dist/hooks/magic-context/execute-status.d.ts +2 -1
- package/dist/hooks/magic-context/execute-status.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/lkg-slot.d.ts +20 -0
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-state-sync.d.ts +9 -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/module-wire.d.ts +6 -1
- package/dist/hooks/magic-context/module-wire.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 +20 -0
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/strip-content.d.ts +31 -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 +12 -3
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +10 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
- package/dist/{index-3nfgbsp4.js → index-0ze9p983.js} +897 -114
- package/dist/{index-nsx2n9eg.js → index-4cz5nfxf.js} +3 -3
- package/dist/{index-0c9dmw9m.js → index-8mtsfhyg.js} +33 -3
- package/dist/{index-f1tnb3vm.js → index-8pqfcghm.js} +21 -9
- package/dist/{index-37rt2bka.js → index-cae89eas.js} +26 -7
- package/dist/index-cgyfn1s2.js +53 -0
- package/dist/{index-z8v31d85.js → index-crke4z5j.js} +144 -14
- package/dist/{index-tsgs7td8.js → index-pck9vadr.js} +1 -1
- package/dist/index-q0t189ay.js +741 -0
- package/dist/{index-3tnkymva.js → index-sw8gnq34.js} +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1952 -795
- package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.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-c3ff16k7.js} +8 -7
- package/dist/{safe-notification-target-c11fdda2.js → safe-notification-target-b693f9x3.js} +2 -2
- package/dist/{send-session-notification-bgtf4853.js → send-session-notification-dxske5zm.js} +3 -3
- package/dist/shared/conflict-detector.d.ts +63 -0
- package/dist/shared/conflict-detector.d.ts.map +1 -1
- package/dist/shared/conflict-fixer.d.ts.map +1 -1
- package/dist/shared/harness-provider-map.d.ts +8 -0
- package/dist/shared/harness-provider-map.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/logger.d.ts +8 -0
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts +5 -0
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
- package/dist/shared/prompt-surface.d.ts +7 -3
- package/dist/shared/prompt-surface.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +14 -0
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +32 -6
- 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/shared/window-geometry.d.ts +89 -0
- package/dist/shared/window-geometry.d.ts.map +1 -0
- package/dist/{storage-meta-session-kh5egr11.js → storage-meta-session-mk4gsnke.js} +4 -3
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/config/index.test.ts +23 -0
- package/src/config/index.ts +43 -9
- package/src/config/latch-permanence-guard.test.ts +218 -0
- package/src/config/project-security.ts +10 -1
- package/src/config/schema/magic-context.test.ts +2 -1
- package/src/config/schema/magic-context.ts +13 -3
- package/src/shared/__fixtures__/window-overlay.v1.json +337 -0
- package/src/shared/conflict-detector.test.ts +176 -1
- package/src/shared/conflict-detector.ts +126 -3
- package/src/shared/conflict-fixer.test.ts +174 -0
- package/src/shared/conflict-fixer.ts +66 -70
- package/src/shared/harness-provider-map.test.ts +20 -0
- package/src/shared/harness-provider-map.ts +19 -0
- 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/logger.test.ts +119 -0
- package/src/shared/logger.ts +33 -15
- package/src/shared/models-dev-cache.test.ts +17 -0
- package/src/shared/models-dev-cache.ts +58 -6
- package/src/shared/prompt-surface-runtime.ts +5 -3
- package/src/shared/prompt-surface.test.ts +91 -2
- package/src/shared/prompt-surface.ts +35 -11
- package/src/shared/rpc-client.ts +4 -2
- package/src/shared/rpc-types.ts +14 -0
- package/src/shared/rpc-utils.test.ts +173 -13
- package/src/shared/rpc-utils.ts +202 -18
- package/src/shared/tui-preferences.test.ts +52 -2
- package/src/shared/tui-preferences.ts +42 -17
- package/src/shared/window-geometry.test.ts +309 -0
- package/src/shared/window-geometry.ts +629 -0
- package/src/tui/data/context-db.ts +5 -0
- package/src/tui/index.tsx +22 -0
- package/src/tui-compiled/data/context-db.ts +5 -0
- package/src/tui-compiled/index.tsx +319 -255
- package/dist/index-wgvxkqpx.js +0 -342
|
@@ -6,6 +6,9 @@ import type { readFileSync } from "node:fs";
|
|
|
6
6
|
import {
|
|
7
7
|
__resetRpcIdentityTestHooks,
|
|
8
8
|
__setRpcIdentityTestHooks,
|
|
9
|
+
discoverLivePiProcessIds,
|
|
10
|
+
inspectLivePiProcesses,
|
|
11
|
+
isPidAlive,
|
|
9
12
|
isPidIdentityPlausible,
|
|
10
13
|
type RpcPortFileRecord,
|
|
11
14
|
} from "./rpc-utils";
|
|
@@ -40,10 +43,148 @@ function psOutput(output: string | Error): typeof execFileSync {
|
|
|
40
43
|
}) as typeof execFileSync;
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
function tasklistOutput(entries: Array<[number, string]>): string {
|
|
47
|
+
return [
|
|
48
|
+
'"Image Name","PID","Session Name","Session#","Mem Usage"',
|
|
49
|
+
...entries.map(([pid, command]) => `"${command}","${pid}","Console","1","10,000 K"`),
|
|
50
|
+
].join("\r\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
43
53
|
afterEach(() => {
|
|
44
54
|
__resetRpcIdentityTestHooks();
|
|
45
55
|
});
|
|
46
56
|
|
|
57
|
+
describe("discoverLivePiProcessIds", () => {
|
|
58
|
+
test("finds Pi-family harness commands while excluding the current process", () => {
|
|
59
|
+
__setRpcIdentityTestHooks({
|
|
60
|
+
processListExecFileSync: (() =>
|
|
61
|
+
[
|
|
62
|
+
` ${process.pid} /usr/local/bin/pi`,
|
|
63
|
+
" 41001 /usr/local/bin/pi --model test",
|
|
64
|
+
" 41002 node /opt/node_modules/@mariozechner/pi-coding-agent/dist/cli.js",
|
|
65
|
+
" 41003 bun /opt/node_modules/@oh-my-pi/pi-coding-agent/dist/cli.js",
|
|
66
|
+
" 41004 /Applications/OpenCode.app/Contents/MacOS/opencode",
|
|
67
|
+
" 41005 node /workspace/pi-plugin/src/index.ts",
|
|
68
|
+
" 41006 npm install @earendil-works/pi-coding-agent",
|
|
69
|
+
" 41007 /usr/local/bin/omp --model test",
|
|
70
|
+
].join("\n")) as typeof execFileSync,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(discoverLivePiProcessIds()).toEqual([41001, 41002, 41003, 41007]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("reports uncertainty instead of treating an unavailable process list as empty", () => {
|
|
77
|
+
__setRpcIdentityTestHooks({
|
|
78
|
+
processListExecFileSync: (() => {
|
|
79
|
+
throw new Error("ps unavailable");
|
|
80
|
+
}) as typeof execFileSync,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(inspectLivePiProcesses()).toEqual({
|
|
84
|
+
state: "unreadable",
|
|
85
|
+
processIds: [],
|
|
86
|
+
error: "ps unavailable",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("uses tasklist instead of ps on Windows", () => {
|
|
91
|
+
const calls: string[] = [];
|
|
92
|
+
__setRpcIdentityTestHooks({
|
|
93
|
+
platform: "win32",
|
|
94
|
+
processListExecFileSync: ((file: string | URL) => {
|
|
95
|
+
calls.push(String(file));
|
|
96
|
+
return tasklistOutput([
|
|
97
|
+
[process.pid, "pi.exe"],
|
|
98
|
+
[41001, "pi.exe"],
|
|
99
|
+
[41002, "opencode.exe"],
|
|
100
|
+
]);
|
|
101
|
+
}) as typeof execFileSync,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(inspectLivePiProcesses()).toEqual({
|
|
105
|
+
state: "known",
|
|
106
|
+
processIds: [41001],
|
|
107
|
+
});
|
|
108
|
+
expect(calls).toEqual(["tasklist"]);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("isPidAlive", () => {
|
|
113
|
+
test("distinguishes confirmed, dead, and inaccessible PID probes", () => {
|
|
114
|
+
const probeFailure = (code: string): NodeJS.ErrnoException => {
|
|
115
|
+
const error = new Error(`kill failed: ${code}`) as NodeJS.ErrnoException;
|
|
116
|
+
error.code = code;
|
|
117
|
+
return error;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
__setRpcIdentityTestHooks({
|
|
121
|
+
processKill: (() => true) as typeof process.kill,
|
|
122
|
+
});
|
|
123
|
+
expect(isPidAlive(PID)).toBe("alive");
|
|
124
|
+
|
|
125
|
+
__setRpcIdentityTestHooks({
|
|
126
|
+
processKill: (() => {
|
|
127
|
+
throw probeFailure("ESRCH");
|
|
128
|
+
}) as typeof process.kill,
|
|
129
|
+
});
|
|
130
|
+
expect(isPidAlive(PID)).toBe("dead");
|
|
131
|
+
|
|
132
|
+
__setRpcIdentityTestHooks({
|
|
133
|
+
processKill: (() => {
|
|
134
|
+
throw probeFailure("EPERM");
|
|
135
|
+
}) as typeof process.kill,
|
|
136
|
+
});
|
|
137
|
+
expect(isPidAlive(PID)).toBe("inconclusive");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("uses tasklist for Windows liveness and captures probe stderr", () => {
|
|
141
|
+
const calls: Array<{ file: string; args: readonly string[]; stdio: unknown }> = [];
|
|
142
|
+
__setRpcIdentityTestHooks({
|
|
143
|
+
platform: "win32",
|
|
144
|
+
execFileSync: ((
|
|
145
|
+
file: string | URL,
|
|
146
|
+
args: readonly string[] = [],
|
|
147
|
+
options: { stdio?: unknown } = {},
|
|
148
|
+
) => {
|
|
149
|
+
calls.push({ file: String(file), args, stdio: options.stdio });
|
|
150
|
+
if (String(file) === "ps") throw new Error("ps must not run on Windows");
|
|
151
|
+
return tasklistOutput([[PID, "OpenCode.exe"]]);
|
|
152
|
+
}) as typeof execFileSync,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(isPidAlive(PID)).toBe("alive");
|
|
156
|
+
expect(calls).toEqual([
|
|
157
|
+
{
|
|
158
|
+
file: "tasklist",
|
|
159
|
+
args: ["/FO", "CSV", "/FI", `PID eq ${PID}`],
|
|
160
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
161
|
+
},
|
|
162
|
+
]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("treats a successful tasklist no-match response as dead", () => {
|
|
166
|
+
__setRpcIdentityTestHooks({
|
|
167
|
+
platform: "win32",
|
|
168
|
+
execFileSync: (() =>
|
|
169
|
+
"INFO: No tasks are running which match the specified criteria.") as typeof execFileSync,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
expect(isPidAlive(PID)).toBe("dead");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("returns inconclusive when the Windows tasklist probe cannot spawn", () => {
|
|
176
|
+
__setRpcIdentityTestHooks({
|
|
177
|
+
platform: "win32",
|
|
178
|
+
execFileSync: (() => {
|
|
179
|
+
throw new Error("tasklist unavailable");
|
|
180
|
+
}) as typeof execFileSync,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(isPidAlive(PID)).toBe("inconclusive");
|
|
184
|
+
expect(isPidIdentityPlausible(record(0))).toBe("inconclusive");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
47
188
|
describe("isPidIdentityPlausible", () => {
|
|
48
189
|
test("rejects a reused Linux PID when proc start time is substantially newer", () => {
|
|
49
190
|
const readPaths: string[] = [];
|
|
@@ -63,7 +204,7 @@ describe("isPidIdentityPlausible", () => {
|
|
|
63
204
|
}) as typeof execFileSync,
|
|
64
205
|
});
|
|
65
206
|
|
|
66
|
-
expect(isPidIdentityPlausible(record(500_000))).toBe(
|
|
207
|
+
expect(isPidIdentityPlausible(record(500_000))).toBe("implausible");
|
|
67
208
|
expect(readPaths).toEqual([`/proc/${PID}/stat`, "/proc/uptime"]);
|
|
68
209
|
});
|
|
69
210
|
|
|
@@ -79,10 +220,10 @@ describe("isPidIdentityPlausible", () => {
|
|
|
79
220
|
|
|
80
221
|
// The mocked process start is 1,100,000ms. The 120s tolerance is part of
|
|
81
222
|
// the contract because port-file creation follows process startup.
|
|
82
|
-
expect(isPidIdentityPlausible(record(1_000_000))).toBe(
|
|
223
|
+
expect(isPidIdentityPlausible(record(1_000_000))).toBe("plausible");
|
|
83
224
|
});
|
|
84
225
|
|
|
85
|
-
test("
|
|
226
|
+
test("reports an unreadable Linux start-time probe as inconclusive", () => {
|
|
86
227
|
__setRpcIdentityTestHooks({
|
|
87
228
|
platform: "linux",
|
|
88
229
|
readFileSync: linuxFiles({
|
|
@@ -90,17 +231,17 @@ describe("isPidIdentityPlausible", () => {
|
|
|
90
231
|
}),
|
|
91
232
|
});
|
|
92
233
|
|
|
93
|
-
expect(isPidIdentityPlausible(record(500_000))).toBe(
|
|
234
|
+
expect(isPidIdentityPlausible(record(500_000))).toBe("inconclusive");
|
|
94
235
|
});
|
|
95
236
|
|
|
96
|
-
test("uses the legacy Linux command fallback and
|
|
237
|
+
test("uses the legacy Linux command fallback and distinguishes probe errors", () => {
|
|
97
238
|
__setRpcIdentityTestHooks({
|
|
98
239
|
platform: "linux",
|
|
99
240
|
readFileSync: linuxFiles({
|
|
100
241
|
[`/proc/${PID}/cmdline`]: "/usr/sbin/opendkim --config /etc/opendkim.conf",
|
|
101
242
|
}),
|
|
102
243
|
});
|
|
103
|
-
expect(isPidIdentityPlausible(record(0))).toBe(
|
|
244
|
+
expect(isPidIdentityPlausible(record(0))).toBe("implausible");
|
|
104
245
|
|
|
105
246
|
__setRpcIdentityTestHooks({
|
|
106
247
|
platform: "linux",
|
|
@@ -108,7 +249,7 @@ describe("isPidIdentityPlausible", () => {
|
|
|
108
249
|
[`/proc/${PID}/cmdline`]: "/usr/local/bin/opencode serve",
|
|
109
250
|
}),
|
|
110
251
|
});
|
|
111
|
-
expect(isPidIdentityPlausible(record(0))).toBe(
|
|
252
|
+
expect(isPidIdentityPlausible(record(0))).toBe("plausible");
|
|
112
253
|
|
|
113
254
|
__setRpcIdentityTestHooks({
|
|
114
255
|
platform: "linux",
|
|
@@ -116,7 +257,7 @@ describe("isPidIdentityPlausible", () => {
|
|
|
116
257
|
[`/proc/${PID}/cmdline`]: new Error("procfs unavailable"),
|
|
117
258
|
}),
|
|
118
259
|
});
|
|
119
|
-
expect(isPidIdentityPlausible(record(0))).toBe(
|
|
260
|
+
expect(isPidIdentityPlausible(record(0))).toBe("inconclusive");
|
|
120
261
|
});
|
|
121
262
|
|
|
122
263
|
test("uses ps start time and command probes on non-Linux platforms", () => {
|
|
@@ -126,7 +267,7 @@ describe("isPidIdentityPlausible", () => {
|
|
|
126
267
|
});
|
|
127
268
|
expect(
|
|
128
269
|
isPidIdentityPlausible(record(Date.parse("Mon Aug 7 00:00:00 1970") - 121_000)),
|
|
129
|
-
).toBe(
|
|
270
|
+
).toBe("implausible");
|
|
130
271
|
|
|
131
272
|
__setRpcIdentityTestHooks({
|
|
132
273
|
platform: "darwin",
|
|
@@ -134,24 +275,43 @@ describe("isPidIdentityPlausible", () => {
|
|
|
134
275
|
});
|
|
135
276
|
expect(
|
|
136
277
|
isPidIdentityPlausible(record(Date.parse("Mon Aug 7 00:00:00 1970") - 120_000)),
|
|
137
|
-
).toBe(
|
|
278
|
+
).toBe("plausible");
|
|
138
279
|
|
|
139
280
|
__setRpcIdentityTestHooks({
|
|
140
281
|
platform: "darwin",
|
|
141
282
|
execFileSync: psOutput("/usr/sbin/opendkim -f"),
|
|
142
283
|
});
|
|
143
|
-
expect(isPidIdentityPlausible(record(0))).toBe(
|
|
284
|
+
expect(isPidIdentityPlausible(record(0))).toBe("implausible");
|
|
144
285
|
|
|
145
286
|
__setRpcIdentityTestHooks({
|
|
146
287
|
platform: "darwin",
|
|
147
288
|
execFileSync: psOutput("/Applications/OpenCode.app/Contents/MacOS/opencode"),
|
|
148
289
|
});
|
|
149
|
-
expect(isPidIdentityPlausible(record(0))).toBe(
|
|
290
|
+
expect(isPidIdentityPlausible(record(0))).toBe("plausible");
|
|
150
291
|
|
|
151
292
|
__setRpcIdentityTestHooks({
|
|
152
293
|
platform: "darwin",
|
|
153
294
|
execFileSync: psOutput(new Error("ps unavailable")),
|
|
154
295
|
});
|
|
155
|
-
expect(isPidIdentityPlausible(record(0))).toBe(
|
|
296
|
+
expect(isPidIdentityPlausible(record(0))).toBe("inconclusive");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("uses tasklist for the Windows command fallback and skips unavailable start time", () => {
|
|
300
|
+
const calls: Array<{ file: string; args: readonly string[] }> = [];
|
|
301
|
+
__setRpcIdentityTestHooks({
|
|
302
|
+
platform: "win32",
|
|
303
|
+
execFileSync: ((file: string | URL, args: readonly string[] = []) => {
|
|
304
|
+
calls.push({ file: String(file), args });
|
|
305
|
+
if (String(file) === "ps") throw new Error("ps must not run on Windows");
|
|
306
|
+
return tasklistOutput([[PID, "OpenCode.exe"]]);
|
|
307
|
+
}) as typeof execFileSync,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(isPidIdentityPlausible(record(0))).toBe("plausible");
|
|
311
|
+
expect(calls).toEqual([{ file: "tasklist", args: ["/FO", "CSV", "/FI", `PID eq ${PID}`] }]);
|
|
312
|
+
|
|
313
|
+
calls.length = 0;
|
|
314
|
+
expect(isPidIdentityPlausible(record(NOW_MS))).toBe("inconclusive");
|
|
315
|
+
expect(calls).toEqual([]);
|
|
156
316
|
});
|
|
157
317
|
});
|
package/src/shared/rpc-utils.ts
CHANGED
|
@@ -50,13 +50,22 @@ export function legacyRpcPortFilePath(storageDir: string, directory: string): st
|
|
|
50
50
|
return join(rpcPortDir(storageDir, directory), "port");
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
export
|
|
54
|
-
|
|
53
|
+
export type PidLiveness = "alive" | "dead" | "inconclusive";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check whether the platform confirms a PID is live without treating a denied
|
|
57
|
+
* probe as confirmation. Windows uses tasklist because MSYS2/Cygwin ps does not
|
|
58
|
+
* support the options used by the Unix probe. Sandboxes commonly reject
|
|
59
|
+
* `kill(pid, 0)` with EPERM even when the PID does not exist outside their view.
|
|
60
|
+
*/
|
|
61
|
+
export function isPidAlive(pid: number): PidLiveness {
|
|
62
|
+
if (!Number.isInteger(pid) || pid <= 0) return "dead";
|
|
63
|
+
if (rpcIdentityPlatform === "win32") return readWindowsProcess(pid).state;
|
|
55
64
|
try {
|
|
56
|
-
|
|
57
|
-
return
|
|
58
|
-
} catch (
|
|
59
|
-
return (
|
|
65
|
+
rpcIdentityProcessKill(pid, 0);
|
|
66
|
+
return "alive";
|
|
67
|
+
} catch (error) {
|
|
68
|
+
return (error as NodeJS.ErrnoException).code === "ESRCH" ? "dead" : "inconclusive";
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
71
|
|
|
@@ -64,9 +73,14 @@ const RPC_IDENTITY_SKEW_TOLERANCE_MS = 120_000;
|
|
|
64
73
|
const LINUX_CLOCK_TICKS_PER_SECOND = 100;
|
|
65
74
|
const PS_PROBE_TIMEOUT_MS = 1_000;
|
|
66
75
|
const OPEN_CODE_COMMAND_MARKERS = ["opencode", "node", "bun", "electron"];
|
|
76
|
+
const TASKLIST_NO_TASKS_PATTERN =
|
|
77
|
+
/^INFO:\s+No tasks are running which match the specified criteria\.?$/im;
|
|
67
78
|
|
|
68
79
|
let rpcIdentityReadFileSync: typeof readFileSync = readFileSync;
|
|
69
80
|
let rpcIdentityExecFileSync: typeof execFileSync = execFileSync;
|
|
81
|
+
let rpcIdentityProcessKill: typeof process.kill = process.kill;
|
|
82
|
+
let rpcProcessListExecFileSync: typeof execFileSync = execFileSync;
|
|
83
|
+
let rpcProcessListTestOverride = false;
|
|
70
84
|
let rpcIdentityPlatform: NodeJS.Platform = process.platform;
|
|
71
85
|
let rpcIdentityNowMs: () => number = () => Date.now();
|
|
72
86
|
|
|
@@ -114,6 +128,7 @@ function readPsProcessStartTime(pid: number): number | null {
|
|
|
114
128
|
const output = rpcIdentityExecFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
115
129
|
encoding: "utf8",
|
|
116
130
|
timeout: PS_PROBE_TIMEOUT_MS,
|
|
131
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
117
132
|
});
|
|
118
133
|
const processStartTime = Date.parse(String(output).trim());
|
|
119
134
|
return Number.isFinite(processStartTime) ? processStartTime : null;
|
|
@@ -122,6 +137,72 @@ function readPsProcessStartTime(pid: number): number | null {
|
|
|
122
137
|
}
|
|
123
138
|
}
|
|
124
139
|
|
|
140
|
+
interface ProcessListEntry {
|
|
141
|
+
pid: number;
|
|
142
|
+
command: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseCsvLine(line: string): string[] | null {
|
|
146
|
+
const fields: string[] = [];
|
|
147
|
+
let field = "";
|
|
148
|
+
let quoted = false;
|
|
149
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
150
|
+
const character = line[index];
|
|
151
|
+
if (character === '"') {
|
|
152
|
+
if (quoted && line[index + 1] === '"') {
|
|
153
|
+
field += '"';
|
|
154
|
+
index += 1;
|
|
155
|
+
} else {
|
|
156
|
+
quoted = !quoted;
|
|
157
|
+
}
|
|
158
|
+
} else if (character === "," && !quoted) {
|
|
159
|
+
fields.push(field);
|
|
160
|
+
field = "";
|
|
161
|
+
} else {
|
|
162
|
+
field += character;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (quoted) return null;
|
|
166
|
+
fields.push(field);
|
|
167
|
+
return fields;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function parseTasklistOutput(output: string): ProcessListEntry[] | null {
|
|
171
|
+
const entries: ProcessListEntry[] = [];
|
|
172
|
+
let sawHeader = false;
|
|
173
|
+
for (const rawLine of output.split(/\r?\n/)) {
|
|
174
|
+
const line = rawLine.trim();
|
|
175
|
+
if (!line) continue;
|
|
176
|
+
if (TASKLIST_NO_TASKS_PATTERN.test(line)) return [];
|
|
177
|
+
const fields = parseCsvLine(line);
|
|
178
|
+
if (!fields) continue;
|
|
179
|
+
if (fields[1]?.trim().toLowerCase() === "pid") {
|
|
180
|
+
sawHeader = true;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const pid = Number(fields[1]);
|
|
184
|
+
if (!Number.isInteger(pid) || pid <= 0 || !fields[0]) continue;
|
|
185
|
+
entries.push({ pid, command: fields[0] });
|
|
186
|
+
}
|
|
187
|
+
return entries.length > 0 || sawHeader ? entries : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function readWindowsProcess(pid: number): { state: PidLiveness; command?: string } {
|
|
191
|
+
try {
|
|
192
|
+
const output = rpcIdentityExecFileSync("tasklist", ["/FO", "CSV", "/FI", `PID eq ${pid}`], {
|
|
193
|
+
encoding: "utf8",
|
|
194
|
+
timeout: PS_PROBE_TIMEOUT_MS,
|
|
195
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
196
|
+
});
|
|
197
|
+
const entries = parseTasklistOutput(String(output));
|
|
198
|
+
if (entries === null) return { state: "inconclusive" };
|
|
199
|
+
const process = entries.find((entry) => entry.pid === pid);
|
|
200
|
+
return process ? { state: "alive", command: process.command } : { state: "dead" };
|
|
201
|
+
} catch {
|
|
202
|
+
return { state: "inconclusive" };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
125
206
|
function readLinuxProcessCommand(pid: number): string | null {
|
|
126
207
|
try {
|
|
127
208
|
return String(rpcIdentityReadFileSync(`/proc/${pid}/cmdline`, "utf8"));
|
|
@@ -135,6 +216,7 @@ function readPsProcessCommand(pid: number): string | null {
|
|
|
135
216
|
const output = rpcIdentityExecFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
|
136
217
|
encoding: "utf8",
|
|
137
218
|
timeout: PS_PROBE_TIMEOUT_MS,
|
|
219
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
138
220
|
});
|
|
139
221
|
return String(output);
|
|
140
222
|
} catch {
|
|
@@ -151,39 +233,53 @@ function commandLooksLikeOpenCode(command: string): boolean {
|
|
|
151
233
|
* Verify that a live PID still belongs to the process that wrote a port record.
|
|
152
234
|
*
|
|
153
235
|
* A PID can be reused after its original process exits. On Linux, procfs gives
|
|
154
|
-
* us a process start time without spawning a helper;
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
236
|
+
* us a process start time without spawning a helper; macOS and other Unix-like
|
|
237
|
+
* platforms use `ps`, while Windows uses `tasklist`, only on this cold
|
|
238
|
+
* database-open guard path. Legacy records without a start time use a weaker
|
|
239
|
+
* command-name check. A failed filesystem or process probe is inconclusive,
|
|
240
|
+
* not proof that this port record still belongs to OpenCode.
|
|
158
241
|
*/
|
|
159
|
-
export
|
|
160
|
-
|
|
242
|
+
export type PidIdentityPlausibility = "plausible" | "implausible" | "inconclusive";
|
|
243
|
+
|
|
244
|
+
export function isPidIdentityPlausible(record: RpcPortFileRecord): PidIdentityPlausibility {
|
|
245
|
+
if (!Number.isInteger(record.pid) || record.pid <= 0) return "implausible";
|
|
161
246
|
|
|
162
247
|
if (Number.isFinite(record.started_at) && record.started_at > 0) {
|
|
163
248
|
const processStartTime =
|
|
164
249
|
rpcIdentityPlatform === "linux"
|
|
165
250
|
? readLinuxProcessStartTime(record.pid)
|
|
166
|
-
:
|
|
167
|
-
|
|
168
|
-
|
|
251
|
+
: rpcIdentityPlatform === "win32"
|
|
252
|
+
? null
|
|
253
|
+
: readPsProcessStartTime(record.pid);
|
|
254
|
+
if (processStartTime === null) return "inconclusive";
|
|
255
|
+
return processStartTime <= record.started_at + RPC_IDENTITY_SKEW_TOLERANCE_MS
|
|
256
|
+
? "plausible"
|
|
257
|
+
: "implausible";
|
|
169
258
|
}
|
|
170
259
|
|
|
171
260
|
const command =
|
|
172
261
|
rpcIdentityPlatform === "linux"
|
|
173
262
|
? readLinuxProcessCommand(record.pid)
|
|
174
|
-
:
|
|
175
|
-
|
|
176
|
-
|
|
263
|
+
: rpcIdentityPlatform === "win32"
|
|
264
|
+
? (readWindowsProcess(record.pid).command ?? null)
|
|
265
|
+
: readPsProcessCommand(record.pid);
|
|
266
|
+
if (command === null) return "inconclusive";
|
|
267
|
+
return commandLooksLikeOpenCode(command) ? "plausible" : "implausible";
|
|
177
268
|
}
|
|
178
269
|
|
|
179
270
|
export function __setRpcIdentityTestHooks(hooks: {
|
|
180
271
|
readFileSync?: typeof readFileSync;
|
|
181
272
|
execFileSync?: typeof execFileSync;
|
|
273
|
+
processKill?: typeof process.kill;
|
|
274
|
+
processListExecFileSync?: typeof execFileSync;
|
|
182
275
|
platform?: NodeJS.Platform;
|
|
183
276
|
nowMs?: () => number;
|
|
184
277
|
}): void {
|
|
185
278
|
rpcIdentityReadFileSync = hooks.readFileSync ?? readFileSync;
|
|
186
279
|
rpcIdentityExecFileSync = hooks.execFileSync ?? execFileSync;
|
|
280
|
+
rpcIdentityProcessKill = hooks.processKill ?? process.kill;
|
|
281
|
+
rpcProcessListExecFileSync = hooks.processListExecFileSync ?? execFileSync;
|
|
282
|
+
rpcProcessListTestOverride = hooks.processListExecFileSync !== undefined;
|
|
187
283
|
rpcIdentityPlatform = hooks.platform ?? process.platform;
|
|
188
284
|
rpcIdentityNowMs = hooks.nowMs ?? (() => Date.now());
|
|
189
285
|
}
|
|
@@ -191,10 +287,98 @@ export function __setRpcIdentityTestHooks(hooks: {
|
|
|
191
287
|
export function __resetRpcIdentityTestHooks(): void {
|
|
192
288
|
rpcIdentityReadFileSync = readFileSync;
|
|
193
289
|
rpcIdentityExecFileSync = execFileSync;
|
|
290
|
+
rpcIdentityProcessKill = process.kill;
|
|
291
|
+
rpcProcessListExecFileSync = execFileSync;
|
|
292
|
+
rpcProcessListTestOverride = false;
|
|
194
293
|
rpcIdentityPlatform = process.platform;
|
|
195
294
|
rpcIdentityNowMs = () => Date.now();
|
|
196
295
|
}
|
|
197
296
|
|
|
297
|
+
function commandLooksLikePi(command: string): boolean {
|
|
298
|
+
const normalized = command.trim().toLowerCase().replaceAll("\\", "/");
|
|
299
|
+
const tokens = normalized.split(/\s+/).filter(Boolean);
|
|
300
|
+
const executableName = (token: string | undefined): string =>
|
|
301
|
+
(token ?? "").split("/").at(-1) ?? "";
|
|
302
|
+
const first = executableName(tokens[0]).replace(/\.exe$/, "");
|
|
303
|
+
if (["pi", "pi.cmd", "omp", "oh-my-pi"].includes(first)) return true;
|
|
304
|
+
if (["node", "bun", "deno"].includes(first)) {
|
|
305
|
+
const script = executableName(tokens[1]);
|
|
306
|
+
return (
|
|
307
|
+
["pi", "pi.js", "pi.mjs", "pi.cjs"].includes(script) ||
|
|
308
|
+
normalized.includes("pi-coding-agent")
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Result of checking whether Pi/OMP processes may currently hold the shared database. */
|
|
315
|
+
export interface PiProcessDiscovery {
|
|
316
|
+
state: "known" | "unreadable";
|
|
317
|
+
processIds: number[];
|
|
318
|
+
error?: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Inspect Pi/OMP processes without converting a failed process-list probe into
|
|
323
|
+
* false evidence that no harness is running. Callers choose their own policy
|
|
324
|
+
* for the unreadable state: destructive maintenance can fail closed, while a
|
|
325
|
+
* migration guard can proceed after reporting that no live Pi process was confirmed.
|
|
326
|
+
*/
|
|
327
|
+
export function inspectLivePiProcesses(): PiProcessDiscovery {
|
|
328
|
+
if (process.env.NODE_ENV === "test" && !rpcProcessListTestOverride) {
|
|
329
|
+
return { state: "known", processIds: [] };
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
const isWindows = rpcIdentityPlatform === "win32";
|
|
333
|
+
const output = String(
|
|
334
|
+
rpcProcessListExecFileSync(
|
|
335
|
+
isWindows ? "tasklist" : "ps",
|
|
336
|
+
isWindows ? ["/FO", "CSV"] : ["-axo", "pid=,command="],
|
|
337
|
+
{
|
|
338
|
+
encoding: "utf8",
|
|
339
|
+
timeout: PS_PROBE_TIMEOUT_MS,
|
|
340
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
341
|
+
},
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
const pids = new Set<number>();
|
|
345
|
+
if (isWindows) {
|
|
346
|
+
const entries = parseTasklistOutput(output);
|
|
347
|
+
if (entries === null) {
|
|
348
|
+
return {
|
|
349
|
+
state: "unreadable",
|
|
350
|
+
processIds: [],
|
|
351
|
+
error: "tasklist output unavailable",
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
if (entry.pid === process.pid) continue;
|
|
356
|
+
if (commandLooksLikePi(entry.command)) pids.add(entry.pid);
|
|
357
|
+
}
|
|
358
|
+
} else {
|
|
359
|
+
for (const line of output.split(/\r?\n/)) {
|
|
360
|
+
const match = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
361
|
+
if (!match) continue;
|
|
362
|
+
const pid = Number(match[1]);
|
|
363
|
+
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) continue;
|
|
364
|
+
if (commandLooksLikePi(match[2])) pids.add(pid);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return { state: "known", processIds: [...pids].sort((left, right) => left - right) };
|
|
368
|
+
} catch (error) {
|
|
369
|
+
return {
|
|
370
|
+
state: "unreadable",
|
|
371
|
+
processIds: [],
|
|
372
|
+
error: error instanceof Error ? error.message : String(error),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Enumerate live Pi/OMP harness processes before deciding whether migration can proceed. */
|
|
378
|
+
export function discoverLivePiProcessIds(): number[] {
|
|
379
|
+
return inspectLivePiProcesses().processIds;
|
|
380
|
+
}
|
|
381
|
+
|
|
198
382
|
export function parseRpcPortFile(content: string, fallbackPid = 0): RpcPortFileRecord | null {
|
|
199
383
|
const trimmed = content.trim();
|
|
200
384
|
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);
|