@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.
Files changed (192) hide show
  1. package/dist/config/index.d.ts.map +1 -1
  2. package/dist/config/project-security.d.ts +2 -1
  3. package/dist/config/project-security.d.ts.map +1 -1
  4. package/dist/config/schema/magic-context.d.ts +10 -0
  5. package/dist/config/schema/magic-context.d.ts.map +1 -1
  6. package/dist/{conflict-warning-hook-dw9fs934.js → conflict-warning-hook-an4fxhwf.js} +4 -4
  7. package/dist/{derive-budgets-88mxk2w1.js → derive-budgets-0f8xpyw2.js} +3 -2
  8. package/dist/features/magic-context/context-authority.d.ts +12 -0
  9. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/classify.d.ts +2 -0
  11. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +2 -0
  13. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/lease.d.ts +10 -3
  15. package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/map-memories.d.ts +2 -0
  17. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/module-apply.d.ts +5 -0
  19. package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/promote-primers.d.ts +2 -0
  21. package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/refresh-primers.d.ts +2 -0
  23. package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
  24. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +5 -2
  26. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  27. package/dist/features/magic-context/dreamer/verify.d.ts +2 -0
  28. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  29. package/dist/features/magic-context/memory/embedding-openai.d.ts +3 -1
  30. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  31. package/dist/features/magic-context/memory/project-identity.d.ts +6 -0
  32. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  33. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  34. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  35. package/dist/features/magic-context/mural/compress-cues.d.ts +2 -0
  36. package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
  37. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  38. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  39. package/dist/features/magic-context/schema-fence-probe.d.ts +1 -0
  40. package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -1
  41. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +1 -0
  42. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -1
  43. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  44. package/dist/features/magic-context/smart-notes/wake-plane.d.ts +21 -0
  45. package/dist/features/magic-context/smart-notes/wake-plane.d.ts.map +1 -0
  46. package/dist/features/magic-context/storage-clone.d.ts +6 -0
  47. package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
  48. package/dist/features/magic-context/storage-db.d.ts +14 -3
  49. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  50. package/dist/features/magic-context/storage-identity-merge.d.ts.map +1 -1
  51. package/dist/features/magic-context/storage-meta-persisted.d.ts +22 -0
  52. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  53. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  54. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  55. package/dist/features/magic-context/storage-primers.d.ts +11 -0
  56. package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
  57. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  58. package/dist/features/magic-context/storage-tags.d.ts +2 -0
  59. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  60. package/dist/features/magic-context/storage.d.ts +1 -1
  61. package/dist/features/magic-context/storage.d.ts.map +1 -1
  62. package/dist/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
  63. package/dist/features/magic-context/user-memory/review-user-memories.d.ts.map +1 -1
  64. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +7 -0
  65. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  66. package/dist/features/magic-context/window-report-ledger.d.ts +70 -0
  67. package/dist/features/magic-context/window-report-ledger.d.ts.map +1 -0
  68. package/dist/hooks/magic-context/child-session-spawn.d.ts +1 -0
  69. package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -1
  70. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -1
  72. package/dist/hooks/magic-context/compartment-render-epoch.d.ts +5 -3
  73. package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/embed-session-state.d.ts +1 -1
  75. package/dist/hooks/magic-context/embed-session-state.d.ts.map +1 -1
  76. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  77. package/dist/hooks/magic-context/event-resolvers.d.ts +4 -0
  78. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/execute-status.d.ts +2 -1
  80. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  82. package/dist/hooks/magic-context/inject-compartments.d.ts +8 -0
  83. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/lkg-replay.d.ts +5 -2
  85. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/lkg-slot.d.ts +20 -0
  87. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/module-state-sync.d.ts +9 -0
  89. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/module-transport.d.ts +20 -3
  91. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/module-wire.d.ts +6 -1
  93. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  94. package/dist/hooks/magic-context/read-session-formatting.d.ts +1 -0
  95. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/rust-mode-transform.d.ts +20 -0
  97. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  98. package/dist/hooks/magic-context/strip-content.d.ts +31 -1
  99. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  101. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +12 -3
  102. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  103. package/dist/hooks/magic-context/transform.d.ts +10 -0
  104. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  105. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  106. package/dist/{index-3nfgbsp4.js → index-0ze9p983.js} +897 -114
  107. package/dist/{index-nsx2n9eg.js → index-4cz5nfxf.js} +3 -3
  108. package/dist/{index-0c9dmw9m.js → index-8mtsfhyg.js} +33 -3
  109. package/dist/{index-f1tnb3vm.js → index-8pqfcghm.js} +21 -9
  110. package/dist/{index-37rt2bka.js → index-cae89eas.js} +26 -7
  111. package/dist/index-cgyfn1s2.js +53 -0
  112. package/dist/{index-z8v31d85.js → index-crke4z5j.js} +144 -14
  113. package/dist/{index-tsgs7td8.js → index-pck9vadr.js} +1 -1
  114. package/dist/index-q0t189ay.js +741 -0
  115. package/dist/{index-3tnkymva.js → index-sw8gnq34.js} +1 -1
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +1952 -795
  118. package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
  119. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  120. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  121. package/dist/plugin/rust-tool-backends.d.ts +4 -0
  122. package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
  123. package/dist/{recomp-orchestrator-7wbbvf87.js → recomp-orchestrator-c3ff16k7.js} +8 -7
  124. package/dist/{safe-notification-target-c11fdda2.js → safe-notification-target-b693f9x3.js} +2 -2
  125. package/dist/{send-session-notification-bgtf4853.js → send-session-notification-dxske5zm.js} +3 -3
  126. package/dist/shared/conflict-detector.d.ts +63 -0
  127. package/dist/shared/conflict-detector.d.ts.map +1 -1
  128. package/dist/shared/conflict-fixer.d.ts.map +1 -1
  129. package/dist/shared/harness-provider-map.d.ts +8 -0
  130. package/dist/shared/harness-provider-map.d.ts.map +1 -1
  131. package/dist/shared/jsonc-edit.d.ts +18 -0
  132. package/dist/shared/jsonc-edit.d.ts.map +1 -0
  133. package/dist/shared/jsonc-parser.d.ts +11 -1
  134. package/dist/shared/jsonc-parser.d.ts.map +1 -1
  135. package/dist/shared/logger.d.ts +8 -0
  136. package/dist/shared/logger.d.ts.map +1 -1
  137. package/dist/shared/models-dev-cache.d.ts +5 -0
  138. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  139. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  140. package/dist/shared/prompt-surface.d.ts +7 -3
  141. package/dist/shared/prompt-surface.d.ts.map +1 -1
  142. package/dist/shared/rpc-client.d.ts.map +1 -1
  143. package/dist/shared/rpc-types.d.ts +14 -0
  144. package/dist/shared/rpc-types.d.ts.map +1 -1
  145. package/dist/shared/rpc-utils.d.ts +32 -6
  146. package/dist/shared/rpc-utils.d.ts.map +1 -1
  147. package/dist/shared/tui-preferences.d.ts +9 -0
  148. package/dist/shared/tui-preferences.d.ts.map +1 -1
  149. package/dist/shared/window-geometry.d.ts +89 -0
  150. package/dist/shared/window-geometry.d.ts.map +1 -0
  151. package/dist/{storage-meta-session-kh5egr11.js → storage-meta-session-mk4gsnke.js} +4 -3
  152. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  153. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  154. package/dist/tui/data/context-db.d.ts.map +1 -1
  155. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  156. package/package.json +2 -1
  157. package/src/config/index.test.ts +23 -0
  158. package/src/config/index.ts +43 -9
  159. package/src/config/latch-permanence-guard.test.ts +218 -0
  160. package/src/config/project-security.ts +10 -1
  161. package/src/config/schema/magic-context.test.ts +2 -1
  162. package/src/config/schema/magic-context.ts +13 -3
  163. package/src/shared/__fixtures__/window-overlay.v1.json +337 -0
  164. package/src/shared/conflict-detector.test.ts +176 -1
  165. package/src/shared/conflict-detector.ts +126 -3
  166. package/src/shared/conflict-fixer.test.ts +174 -0
  167. package/src/shared/conflict-fixer.ts +66 -70
  168. package/src/shared/harness-provider-map.test.ts +20 -0
  169. package/src/shared/harness-provider-map.ts +19 -0
  170. package/src/shared/jsonc-edit.ts +295 -0
  171. package/src/shared/jsonc-parser.test.ts +33 -0
  172. package/src/shared/jsonc-parser.ts +54 -2
  173. package/src/shared/logger.test.ts +119 -0
  174. package/src/shared/logger.ts +33 -15
  175. package/src/shared/models-dev-cache.test.ts +17 -0
  176. package/src/shared/models-dev-cache.ts +58 -6
  177. package/src/shared/prompt-surface-runtime.ts +5 -3
  178. package/src/shared/prompt-surface.test.ts +91 -2
  179. package/src/shared/prompt-surface.ts +35 -11
  180. package/src/shared/rpc-client.ts +4 -2
  181. package/src/shared/rpc-types.ts +14 -0
  182. package/src/shared/rpc-utils.test.ts +173 -13
  183. package/src/shared/rpc-utils.ts +202 -18
  184. package/src/shared/tui-preferences.test.ts +52 -2
  185. package/src/shared/tui-preferences.ts +42 -17
  186. package/src/shared/window-geometry.test.ts +309 -0
  187. package/src/shared/window-geometry.ts +629 -0
  188. package/src/tui/data/context-db.ts +5 -0
  189. package/src/tui/index.tsx +22 -0
  190. package/src/tui-compiled/data/context-db.ts +5 -0
  191. package/src/tui-compiled/index.tsx +319 -255
  192. 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(false);
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(true);
223
+ expect(isPidIdentityPlausible(record(1_000_000))).toBe("plausible");
83
224
  });
84
225
 
85
- test("treats an unreadable Linux start-time probe as live", () => {
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(true);
234
+ expect(isPidIdentityPlausible(record(500_000))).toBe("inconclusive");
94
235
  });
95
236
 
96
- test("uses the legacy Linux command fallback and fails closed on probe errors", () => {
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(false);
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(true);
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(true);
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(false);
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(true);
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(false);
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(true);
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(true);
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
  });
@@ -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 function isPidAlive(pid: number): boolean {
54
- if (!Number.isInteger(pid) || pid <= 0) return false;
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
- process.kill(pid, 0);
57
- return true;
58
- } catch (err) {
59
- return (err as NodeJS.ErrnoException).code === "EPERM";
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; other platforms use `ps`
155
- * only on this cold database-open guard path. Legacy records without a start
156
- * time use a weaker command-name check, and every probe failure stays live so
157
- * this identity check cannot weaken the migration guard on uncertainty.
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 function isPidIdentityPlausible(record: RpcPortFileRecord): boolean {
160
- if (!Number.isInteger(record.pid) || record.pid <= 0) return false;
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
- : readPsProcessStartTime(record.pid);
167
- if (processStartTime === null) return true;
168
- return processStartTime <= record.started_at + RPC_IDENTITY_SKEW_TOLERANCE_MS;
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
- : readPsProcessCommand(record.pid);
175
- if (command === null) return true;
176
- return commandLooksLikeOpenCode(command);
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);