@retaw106/mmc-oc 2.3.0 → 2.5.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 (216) hide show
  1. package/dist/cli.js +12 -1
  2. package/dist/config/schema/magic-context.d.ts +33 -0
  3. package/dist/config/schema/magic-context.d.ts.map +1 -1
  4. package/dist/config-init.js +12 -1
  5. package/dist/{context-wt7g4f8k.js → context-xx09sh1g.js} +1985 -603
  6. package/dist/features/magic-context/memory-maintainer/launcher.d.ts +13 -4
  7. package/dist/features/magic-context/memory-maintainer/launcher.d.ts.map +1 -1
  8. package/dist/features/magic-context/memory-maintainer/manual-commands.d.ts.map +1 -1
  9. package/dist/features/magic-context/memory-maintainer/model-chain.d.ts +47 -0
  10. package/dist/features/magic-context/memory-maintainer/model-chain.d.ts.map +1 -0
  11. package/dist/features/magic-context/memory-maintainer/oc2-adapter.d.ts +17 -4
  12. package/dist/features/magic-context/memory-maintainer/oc2-adapter.d.ts.map +1 -1
  13. package/dist/features/magic-context/memory-maintainer/pipeline.d.ts +6 -0
  14. package/dist/features/magic-context/memory-maintainer/pipeline.d.ts.map +1 -1
  15. package/dist/features/magic-context/message-index-async.d.ts +6 -2
  16. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  17. package/dist/features/magic-context/message-index.d.ts +5 -8
  18. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  19. package/dist/features/magic-context/search.d.ts +5 -1
  20. package/dist/features/magic-context/search.d.ts.map +1 -1
  21. package/dist/features/magic-context/storage-db.d.ts +2 -0
  22. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  23. package/dist/features/magic-context/storage-host-instruction-retention.d.ts +4 -0
  24. package/dist/features/magic-context/storage-host-instruction-retention.d.ts.map +1 -0
  25. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  26. package/dist/features/magic-context/storage-meta-shared.d.ts +2 -2
  27. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  28. package/dist/features/magic-context/storage-session-tables.d.ts +9 -1
  29. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
  30. package/dist/features/magic-context/storage-tags.d.ts +4 -0
  31. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  32. package/dist/features/magic-context/transform-decision-log.d.ts +57 -2
  33. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  34. package/dist/hooks/magic-context/__fixtures__/tail-hygiene-refresh.d.ts +34 -0
  35. package/dist/hooks/magic-context/__fixtures__/tail-hygiene-refresh.d.ts.map +1 -0
  36. package/dist/hooks/magic-context/apply-operations.d.ts +3 -1
  37. package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/auto-search-hint.d.ts +3 -0
  39. package/dist/hooks/magic-context/auto-search-hint.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -2
  43. package/dist/hooks/magic-context/compartment-runner-types.d.ts +2 -2
  44. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/compartment-runner.d.ts +4 -0
  47. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/compartment-trigger-error.d.ts +11 -0
  49. package/dist/hooks/magic-context/compartment-trigger-error.d.ts.map +1 -0
  50. package/dist/hooks/magic-context/compartment-trigger.d.ts +1 -1
  51. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +1 -0
  53. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/degraded-pass-refusal.d.ts +2 -0
  55. package/dist/hooks/magic-context/degraded-pass-refusal.d.ts.map +1 -1
  56. package/dist/hooks/magic-context/diagnostic-log-state.d.ts +4 -0
  57. package/dist/hooks/magic-context/diagnostic-log-state.d.ts.map +1 -0
  58. package/dist/hooks/magic-context/edit-marker.d.ts +2 -0
  59. package/dist/hooks/magic-context/edit-marker.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/file-token-coverage.d.ts +11 -0
  62. package/dist/hooks/magic-context/file-token-coverage.d.ts.map +1 -0
  63. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +2 -0
  64. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/host-served-rows.d.ts +10 -0
  67. package/dist/hooks/magic-context/host-served-rows.d.ts.map +1 -1
  68. package/dist/hooks/magic-context/inject-compartments.d.ts +1 -1
  69. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  70. package/dist/hooks/magic-context/lkg-recovery-fence.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  72. package/dist/hooks/magic-context/lkg-slot.d.ts +1 -1
  73. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/nudge-evaluation-log.d.ts +3 -0
  75. package/dist/hooks/magic-context/nudge-evaluation-log.d.ts.map +1 -0
  76. package/dist/hooks/magic-context/pass-outcome.d.ts +2 -0
  77. package/dist/hooks/magic-context/pass-outcome.d.ts.map +1 -1
  78. package/dist/hooks/magic-context/pending-drop-eligibility.d.ts +14 -0
  79. package/dist/hooks/magic-context/pending-drop-eligibility.d.ts.map +1 -0
  80. package/dist/hooks/magic-context/pending-materialization.d.ts +2 -0
  81. package/dist/hooks/magic-context/pending-materialization.d.ts.map +1 -1
  82. package/dist/hooks/magic-context/protected-tail-boundary.d.ts +2 -0
  83. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/provider-visible-parts.d.ts +10 -4
  85. package/dist/hooks/magic-context/provider-visible-parts.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +3 -3
  88. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/render-options.d.ts +19 -0
  90. package/dist/hooks/magic-context/render-options.d.ts.map +1 -0
  91. package/dist/hooks/magic-context/supersession-reclaim.d.ts +5 -3
  92. package/dist/hooks/magic-context/supersession-reclaim.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/system-date-refresh.d.ts +3 -2
  94. package/dist/hooks/magic-context/system-date-refresh.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/system-prompt-sticky-date.d.ts +4 -0
  97. package/dist/hooks/magic-context/system-prompt-sticky-date.d.ts.map +1 -0
  98. package/dist/hooks/magic-context/tag-content-primitives.d.ts +1 -1
  99. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/tag-messages.d.ts +15 -2
  101. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  102. package/dist/hooks/magic-context/tail-hygiene-output-count.d.ts +18 -0
  103. package/dist/hooks/magic-context/tail-hygiene-output-count.d.ts.map +1 -0
  104. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +8 -4
  105. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/text-complete.d.ts +1 -1
  107. package/dist/hooks/magic-context/text-complete.d.ts.map +1 -1
  108. package/dist/hooks/magic-context/todo-view.d.ts +2 -2
  109. package/dist/hooks/magic-context/tool-drop-target.d.ts +3 -2
  110. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/tool-reclaim.d.ts +5 -2
  112. package/dist/hooks/magic-context/tool-reclaim.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +1 -0
  114. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  115. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -0
  116. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  117. package/dist/hooks/magic-context/transform-stage-logger.d.ts +3 -0
  118. package/dist/hooks/magic-context/transform-stage-logger.d.ts.map +1 -1
  119. package/dist/hooks/magic-context/transform.d.ts +4 -9
  120. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  121. package/dist/hooks/magic-context/unresolved-history-boundary.d.ts +5 -1
  122. package/dist/hooks/magic-context/unresolved-history-boundary.d.ts.map +1 -1
  123. package/dist/{index-kw21z5pe.js → index-0d40b4q8.js} +17119 -16569
  124. package/dist/{index-z2w1eq3y.js → index-1vxffj5j.js} +558 -211
  125. package/dist/{index-z2g20msd.js → index-azbmnekv.js} +4 -4
  126. package/dist/{index-18yrysyj.js → index-bpjjcdy9.js} +1 -1
  127. package/dist/{index-a5mw0xp3.js → index-k25thxq7.js} +1 -1
  128. package/dist/{index-3gj1ak4c.js → index-mjf0p7yq.js} +1 -1
  129. package/dist/{index-czba8tw3.js → index-pte3cfs9.js} +226 -14
  130. package/dist/index.js +2 -2
  131. package/dist/{message-time-backfill-sykevdax.js → message-time-backfill-g9py24yt.js} +1 -1
  132. package/dist/model-resolution-cvyrrcjw.js +8 -0
  133. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  134. package/dist/plugin/subagent-tool-policy.d.ts +4 -0
  135. package/dist/plugin/subagent-tool-policy.d.ts.map +1 -1
  136. package/dist/{read-session-chunk-863d6c06.js → read-session-chunk-eb3fy45y.js} +3 -3
  137. package/dist/{recomp-orchestrator-wrjy3qym.js → recomp-orchestrator-mmpy5cb7.js} +6 -6
  138. package/dist/{send-session-notification-cz6ev088.js → send-session-notification-zpqs213d.js} +3 -3
  139. package/dist/shared/model-suggestion-retry.d.ts +23 -0
  140. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  141. package/dist/shared/prompt-surface-runtime.d.ts +5 -0
  142. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  143. package/dist/shared/reclaim-distance.d.ts +33 -0
  144. package/dist/shared/reclaim-distance.d.ts.map +1 -0
  145. package/dist/shared/rpc-discovery-cleanup.d.ts +3 -0
  146. package/dist/shared/rpc-discovery-cleanup.d.ts.map +1 -0
  147. package/dist/shared/rpc-server.d.ts +2 -0
  148. package/dist/shared/rpc-server.d.ts.map +1 -1
  149. package/dist/shared/search-text.d.ts +8 -0
  150. package/dist/shared/search-text.d.ts.map +1 -0
  151. package/dist/shared/sqlite.d.ts +8 -1
  152. package/dist/shared/sqlite.d.ts.map +1 -1
  153. package/dist/shared/stable-json.d.ts +7 -0
  154. package/dist/shared/stable-json.d.ts.map +1 -1
  155. package/dist/shared/storage-file-mutex.d.ts.map +1 -1
  156. package/dist/shared/system-directive.d.ts +2 -0
  157. package/dist/shared/system-directive.d.ts.map +1 -1
  158. package/dist/shared/tail-hygiene-json.d.ts +22 -0
  159. package/dist/shared/tail-hygiene-json.d.ts.map +1 -0
  160. package/dist/shared/user-facing-codes.d.ts +2 -2
  161. package/dist/v2/fold/native-replay.d.ts +11 -2
  162. package/dist/v2/fold/native-replay.d.ts.map +1 -1
  163. package/dist/v2/fold/policy.d.ts.map +1 -1
  164. package/dist/v2/fold/restore.d.ts.map +1 -1
  165. package/dist/v2/hidden-completion.d.ts +20 -1
  166. package/dist/v2/hidden-completion.d.ts.map +1 -1
  167. package/dist/v2/hooks/context.d.ts +1 -1
  168. package/dist/v2/hooks/context.d.ts.map +1 -1
  169. package/dist/v2/hooks/generate.d.ts +36 -1
  170. package/dist/v2/hooks/generate.d.ts.map +1 -1
  171. package/dist/v2/hooks/hidden-child.d.ts +22 -1
  172. package/dist/v2/hooks/hidden-child.d.ts.map +1 -1
  173. package/dist/v2/hooks/lkg-system.d.ts +35 -4
  174. package/dist/v2/hooks/lkg-system.d.ts.map +1 -1
  175. package/dist/v2/hooks/payload.d.ts +10 -1
  176. package/dist/v2/hooks/payload.d.ts.map +1 -1
  177. package/dist/v2/hooks/store.d.ts +2 -0
  178. package/dist/v2/hooks/store.d.ts.map +1 -1
  179. package/dist/v2/hooks/types.d.ts +12 -0
  180. package/dist/v2/hooks/types.d.ts.map +1 -1
  181. package/dist/v2/server.js +16272 -13742
  182. package/dist/v2/store-reader.d.ts +38 -1
  183. package/dist/v2/store-reader.d.ts.map +1 -1
  184. package/dist/web/main.js +262 -17
  185. package/dist/{wrapup-orchestrator-bmybc5p5.js → wrapup-orchestrator-jq589pgr.js} +6 -6
  186. package/package.json +1 -1
  187. package/src/config/latch-permanence-guard.test.ts +8 -0
  188. package/src/config/project-security.test.ts +17 -0
  189. package/src/config/schema/magic-context.test.ts +50 -0
  190. package/src/config/schema/magic-context.ts +34 -1
  191. package/src/shared/model-suggestion-retry.test.ts +52 -0
  192. package/src/shared/model-suggestion-retry.ts +13 -4
  193. package/src/shared/prompt-surface-runtime.ts +12 -0
  194. package/src/shared/reclaim-distance.test.ts +111 -0
  195. package/src/shared/reclaim-distance.ts +91 -0
  196. package/src/shared/root-async-context.probe.mjs +36 -0
  197. package/src/shared/root-async-context.test.ts +49 -0
  198. package/src/shared/rpc-discovery-cleanup.test.ts +125 -0
  199. package/src/shared/rpc-discovery-cleanup.ts +57 -0
  200. package/src/shared/rpc-server.ts +36 -13
  201. package/src/shared/search-text.test.ts +30 -0
  202. package/src/shared/search-text.ts +62 -0
  203. package/src/shared/sqlite-busy-budget-review.test.ts +291 -0
  204. package/src/shared/sqlite-foreground-wait.test.ts +237 -1
  205. package/src/shared/sqlite-turn-deadline.test.ts +286 -0
  206. package/src/shared/sqlite.ts +292 -23
  207. package/src/shared/stable-json.ts +56 -14
  208. package/src/shared/storage-file-mutex.test.ts +51 -2
  209. package/src/shared/storage-file-mutex.ts +11 -1
  210. package/src/shared/system-directive.test.ts +37 -0
  211. package/src/shared/system-directive.ts +19 -1
  212. package/src/shared/tag-transcript.ts +16 -4
  213. package/src/shared/tail-hygiene-json.test.ts +117 -0
  214. package/src/shared/tail-hygiene-json.ts +217 -0
  215. package/src/shared/user-facing-codes.ts +2 -2
  216. package/dist/model-resolution-wazv26n0.js +0 -6
@@ -0,0 +1,125 @@
1
+ import { expect, spyOn, test } from "bun:test";
2
+ import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { reapDeadRpcDiscovery } from "./rpc-discovery-cleanup";
6
+ import { MagicContextRpcServer } from "./rpc-server";
7
+ import * as utils from "./rpc-utils";
8
+ import * as storage from "./storage-permissions";
9
+ import { createTestTempDirFromPath } from "./test-temp-dir";
10
+
11
+ test("RPC startup reaps dead records across projects but keeps live and inconclusive records", async () => {
12
+ const root = createTestTempDirFromPath(join(tmpdir(), "rpc-reap-"));
13
+ const server = new MagicContextRpcServer(root, "/current-project");
14
+ const dir = utils.rpcPortDir(root, "/other-project");
15
+ const empty = utils.rpcPortDir(root, "/empty-project");
16
+ mkdirSync(dir, { recursive: true });
17
+ mkdirSync(empty, { recursive: true });
18
+ for (const pid of [999999, 888888, process.pid])
19
+ writeFileSync(
20
+ join(dir, `port-${pid}.json`),
21
+ JSON.stringify({ port: 1234, pid, token: "do-not-log" }),
22
+ );
23
+ const probe = spyOn(utils, "inspectProcessesAsync").mockResolvedValue({
24
+ pi: { state: "absent", processIds: [] },
25
+ evidence: () => ({ processStartTime: null, commandLine: null, imageName: null }),
26
+ liveness: (pid: number) =>
27
+ pid === 999999 ? "dead" : pid === 888888 ? "inconclusive" : "alive",
28
+ } as Awaited<ReturnType<typeof utils.inspectProcessesAsync>>);
29
+ try {
30
+ await server.start();
31
+ expect(readdirSync(dir).sort()).toEqual(
32
+ [`port-${process.pid}.json`, "port-888888.json"].sort(),
33
+ );
34
+ expect(existsSync(empty)).toBe(false);
35
+ } finally {
36
+ server.stop();
37
+ probe.mockRestore();
38
+ rmSync(root, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ test("cleanup keeps changed, malformed and out-of-scope records and requests fresh evidence", async () => {
43
+ const root = createTestTempDirFromPath(join(tmpdir(), "rpc-reap-race-"));
44
+ const directory = utils.rpcPortDir(root, "/project");
45
+ const outside = join(root, "rpc", "not-a-project-hash");
46
+ mkdirSync(directory, { recursive: true });
47
+ mkdirSync(outside, { recursive: true });
48
+ const path = join(directory, "port-999999.json");
49
+ writeFileSync(path, JSON.stringify({ port: 1234, pid: 999999, token: "old" }));
50
+ writeFileSync(join(directory, "port-888888.json"), "not json");
51
+ writeFileSync(join(directory, "unrelated.json"), "keep");
52
+ mkdirSync(join(directory, "port-777777.json"));
53
+ writeFileSync(join(outside, "port-999999.json"), "keep");
54
+ const probe = spyOn(utils, "inspectProcessesAsync").mockImplementation(async (fresh) => {
55
+ expect(fresh).toBe(true);
56
+ writeFileSync(path, JSON.stringify({ port: 1234, pid: 999999, token: "new" }));
57
+ return {
58
+ pi: { state: "absent", processIds: [] },
59
+ evidence: () => ({ startTime: null, commandLine: null }),
60
+ liveness: () => "dead",
61
+ };
62
+ });
63
+ try {
64
+ await reapDeadRpcDiscovery(root);
65
+ expect(probe).toHaveBeenCalledTimes(1);
66
+ expect(readdirSync(directory)).toHaveLength(4);
67
+ expect(existsSync(join(outside, "port-999999.json"))).toBe(true);
68
+ } finally {
69
+ probe.mockRestore();
70
+ rmSync(root, { recursive: true, force: true });
71
+ }
72
+ });
73
+
74
+ test("stop during startup prevents listening and publishing", async () => {
75
+ const root = createTestTempDirFromPath(join(tmpdir(), "rpc-start-stop-"));
76
+ const server = new MagicContextRpcServer(root, root);
77
+ try {
78
+ const starting = server.start();
79
+ server.stop();
80
+ expect(await starting).toBe(0);
81
+ expect(existsSync(utils.rpcPortDir(root, root))).toBe(false);
82
+ } finally {
83
+ server.stop();
84
+ rmSync(root, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ test.each(["ENOENT", "EACCES"])("publishing retries only ENOENT (%s)", async (code) => {
89
+ const root = createTestTempDirFromPath(join(tmpdir(), "rpc-publish-race-"));
90
+ const server = new MagicContextRpcServer(root, root);
91
+ const original = storage.writeStorageFileAtomicSync;
92
+ let calls = 0;
93
+ const writer = spyOn(storage, "writeStorageFileAtomicSync").mockImplementation((...args) => {
94
+ if (++calls === 1) throw Object.assign(new Error("publish race"), { code });
95
+ return original(...args);
96
+ });
97
+ try {
98
+ await server.start();
99
+ expect(calls).toBe(code === "ENOENT" ? 2 : 1);
100
+ expect(existsSync(utils.rpcPortDir(root, root))).toBe(code === "ENOENT");
101
+ } finally {
102
+ server.stop();
103
+ writer.mockRestore();
104
+ rmSync(root, { recursive: true, force: true });
105
+ }
106
+ });
107
+
108
+ test("RPC stop removes its empty discovery directory but preserves other instances", async () => {
109
+ const root = createTestTempDirFromPath(join(tmpdir(), "rpc-stop-reap-"));
110
+ const first = new MagicContextRpcServer(root, root);
111
+ const second = new MagicContextRpcServer(root, root);
112
+ const directory = utils.rpcPortDir(root, root);
113
+ try {
114
+ await first.start();
115
+ await second.start();
116
+ first.stop();
117
+ expect(readdirSync(directory)).toHaveLength(1);
118
+ second.stop();
119
+ expect(existsSync(directory)).toBe(false);
120
+ } finally {
121
+ first.stop();
122
+ second.stop();
123
+ rmSync(root, { recursive: true, force: true });
124
+ }
125
+ });
@@ -0,0 +1,57 @@
1
+ import { type Dirent, promises as fs } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { inspectProcessesAsync, parseRpcPortFile } from "./rpc-utils";
4
+
5
+ /** Reap only records proven dead; live, unreadable and uncertain records stay. */
6
+ export async function reapDeadRpcDiscovery(storageDir: string): Promise<void> {
7
+ const root = join(storageDir, "rpc");
8
+ let directories: Dirent[];
9
+ try {
10
+ directories = await fs.readdir(root, { withFileTypes: true });
11
+ } catch {
12
+ return;
13
+ }
14
+ const records: { path: string; content: string; pid: number }[] = [];
15
+ const projectDirs: string[] = [];
16
+ for (const entry of directories) {
17
+ if (!entry.isDirectory() || !/^[0-9a-f]{16}$/.test(entry.name)) continue;
18
+ const directory = join(root, entry.name);
19
+ projectDirs.push(directory);
20
+ try {
21
+ for (const name of await fs.readdir(directory)) {
22
+ const match = /^port-(\d+)(?:-[0-9a-f]+)?\.json$/.exec(name);
23
+ if (!match) continue;
24
+ const path = join(directory, name);
25
+ try {
26
+ const content = await fs.readFile(path, "utf8");
27
+ const record = parseRpcPortFile(content, Number(match[1]));
28
+ if (record && record.pid === Number(match[1]))
29
+ records.push({ path, content, pid: record.pid });
30
+ } catch {
31
+ // Another host may be publishing or stopping this record.
32
+ }
33
+ }
34
+ } catch {
35
+ // An inaccessible project must not prevent cleanup of other projects.
36
+ }
37
+ }
38
+ // Read records before a fresh process snapshot so a recently started host
39
+ // cannot be mistaken for a PID missing from an older cached snapshot.
40
+ const processes = records.length ? await inspectProcessesAsync(true) : undefined;
41
+ for (const record of records) {
42
+ if (processes?.liveness(record.pid) !== "dead") continue;
43
+ try {
44
+ if ((await fs.readFile(record.path, "utf8")) === record.content)
45
+ await fs.unlink(record.path);
46
+ } catch {
47
+ // Leave the record for a later pass; never recursively remove its directory.
48
+ }
49
+ }
50
+ for (const directory of projectDirs) {
51
+ try {
52
+ await fs.rmdir(directory);
53
+ } catch {
54
+ // Leave non-empty directories untouched; their owners may not be proven dead.
55
+ }
56
+ }
57
+ }
@@ -1,7 +1,8 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto";
2
- import { readdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { readdirSync, readFileSync, rmdirSync, unlinkSync } from "node:fs";
3
3
  import type { Server, ServerWebSocket } from "bun";
4
4
  import { log } from "./logger";
5
+ import { reapDeadRpcDiscovery } from "./rpc-discovery-cleanup";
5
6
  import {
6
7
  acknowledgeNotifications,
7
8
  drainNotifications,
@@ -82,6 +83,7 @@ export class MagicContextRpcServer {
82
83
  private portFilePath: string;
83
84
  private portDir: string;
84
85
  private startedAt = Date.now();
86
+ private stopped = false;
85
87
  private readonly instanceId = randomBytes(8).toString("hex");
86
88
  private unregisterOwnInstance: (() => void) | null = null;
87
89
  /** Every authenticated WS socket, so dispose can close them all. */
@@ -93,7 +95,10 @@ export class MagicContextRpcServer {
93
95
  // discovers/guesses the port.
94
96
  private readonly token = randomBytes(32).toString("hex");
95
97
 
96
- constructor(storageDir: string, directory: string) {
98
+ constructor(
99
+ private storageDir: string,
100
+ directory: string,
101
+ ) {
97
102
  this.portFilePath = rpcPortFilePath(storageDir, directory, process.pid, this.instanceId);
98
103
  this.portDir = rpcPortDir(storageDir, directory);
99
104
  }
@@ -131,6 +136,9 @@ export class MagicContextRpcServer {
131
136
  log("rpc server skipped: Bun runtime not available (no TUI consumer)");
132
137
  return 0;
133
138
  }
139
+ this.stopped = false;
140
+ await reapDeadRpcDiscovery(this.storageDir);
141
+ if (this.stopped) return 0;
134
142
  this.startedAt = Date.now();
135
143
  const self = this;
136
144
  const server = Bun.serve<WsData>({
@@ -170,17 +178,26 @@ export class MagicContextRpcServer {
170
178
  // The discovery file carries the RPC bearer token. The shared writer
171
179
  // creates its directory and temporary file with owner-only modes.
172
180
  this.unregisterOwnInstance ??= registerOwnRpcServerInstance(this.instanceId);
173
- writeStorageFileAtomicSync(
174
- this.portFilePath,
175
- JSON.stringify({
176
- port: this.port,
177
- pid: process.pid,
178
- started_at: this.startedAt,
179
- kind: "OpenCode server",
180
- token: this.token,
181
- instance_id: this.instanceId,
182
- }),
183
- );
181
+ const publish = () =>
182
+ writeStorageFileAtomicSync(
183
+ this.portFilePath,
184
+ JSON.stringify({
185
+ port: this.port,
186
+ pid: process.pid,
187
+ started_at: this.startedAt,
188
+ kind: "OpenCode server",
189
+ token: this.token,
190
+ instance_id: this.instanceId,
191
+ }),
192
+ );
193
+ try {
194
+ publish();
195
+ } catch (error) {
196
+ // An empty-directory reaper can race the writer before its temp
197
+ // file exists. Recreate the directory and retry that case only.
198
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
199
+ publish();
200
+ }
184
201
  log(`[rpc] server listening on 127.0.0.1:${this.port}`);
185
202
  } catch (err) {
186
203
  log(`[rpc] failed to write port file: ${err}`);
@@ -191,6 +208,7 @@ export class MagicContextRpcServer {
191
208
 
192
209
  /** Stop the server: close every socket, stop accepting, remove port file. */
193
210
  stop(): void {
211
+ this.stopped = true;
194
212
  for (const ws of this.sockets) {
195
213
  try {
196
214
  if (ws.data.authTimer) clearTimeout(ws.data.authTimer);
@@ -211,6 +229,11 @@ export class MagicContextRpcServer {
211
229
  } catch {
212
230
  // Intentional: port file may already be gone
213
231
  }
232
+ try {
233
+ rmdirSync(this.portDir);
234
+ } catch {
235
+ // Other instances' discovery records must survive this server's stop.
236
+ }
214
237
  this.unregisterOwnInstance?.();
215
238
  this.unregisterOwnInstance = null;
216
239
  }
@@ -0,0 +1,30 @@
1
+ import { expect, test } from "bun:test";
2
+ import { cleanSearchText, keywordRelevance, matchedSearchSnippet } from "./search-text";
3
+
4
+ test("notification trees never contribute relevance or display text", () => {
5
+ expect(
6
+ cleanSearchText(
7
+ '<task_cohort id="parent"><task>hidden</task>status</task_cohort> actual answer',
8
+ ),
9
+ ).toBe("actual answer");
10
+ expect(cleanSearchText("kept <task>hidden </task_cohort> still hidden</task> kept")).toBe(
11
+ "kept kept",
12
+ );
13
+ expect(cleanSearchText('<task id="truncated">background result…')).toBe("");
14
+ expect(keywordRelevance("historian cache wiring", "<task>historian cache wiring</task>")).toBe(
15
+ 0,
16
+ );
17
+ });
18
+
19
+ test("keyword relevance measures distinct coverage rather than rank", () => {
20
+ expect(keywordRelevance("explain historian cache wiring", "cache icons")).toBeCloseTo(0.2);
21
+ expect(keywordRelevance("historian cache wiring", "historian cache wiring")).toBeCloseTo(0.8);
22
+ });
23
+
24
+ test("snippets prefer dense passages over an irrelevant header", () => {
25
+ const content = `Header cache. ${"padding ".repeat(150)}Historian cache wiring uses incremental pools. ${"trailing ".repeat(100)}`;
26
+ const snippet = matchedSearchSnippet(content, "historian cache wiring", 80);
27
+ expect(snippet).toContain("Historian cache wiring");
28
+ expect(snippet).not.toContain("Header");
29
+ expect(matchedSearchSnippet(content, "unrelated deployment")).toBe("");
30
+ });
@@ -0,0 +1,62 @@
1
+ import { removeNestedBlocks } from "./system-directive";
2
+
3
+ export function removeTaskNotifications(text: string): string {
4
+ return removeNestedBlocks(text, ["task", "task_cohort"]);
5
+ }
6
+
7
+ /** Remove injected notifications before scoring or rendering; saved hints replay unchanged. */
8
+ export function cleanSearchText(text: string): string {
9
+ return removeNestedBlocks(text, ["system-reminder", "task", "task_cohort"])
10
+ .replace(/\s+/g, " ")
11
+ .trim();
12
+ }
13
+
14
+ const QUERY_STOP_WORDS = new Set(
15
+ "a an and are as at be been by can could did do does for from how i in is it me my of on or our please should that the their them there these they this to us was we were what when where which who why will with would you your".split(
16
+ " ",
17
+ ),
18
+ );
19
+
20
+ function terms(text: string): string[] {
21
+ return [
22
+ ...new Set(
23
+ (text.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? []).filter(
24
+ (term) => !QUERY_STOP_WORDS.has(term),
25
+ ),
26
+ ),
27
+ ];
28
+ }
29
+
30
+ /** Score distinct query coverage independently of other results. */
31
+ export function keywordRelevance(query: string, content: string): number {
32
+ const needles = terms(query);
33
+ if (needles.length === 0) return 0;
34
+ const words = new Set(terms(cleanSearchText(content)));
35
+ return (0.8 * needles.filter((term) => words.has(term)).length) / needles.length;
36
+ }
37
+
38
+ /** Pick the densest matching passage before compression discards matched words. */
39
+ export function matchedSearchSnippet(content: string, query: string, cap = 220): string {
40
+ const text = cleanSearchText(content);
41
+ const needles = new Set(terms(query));
42
+ const matches = [...text.matchAll(/[\p{L}\p{N}_]+/gu)].filter((match) =>
43
+ needles.has(match[0].toLowerCase()),
44
+ );
45
+ if (matches.length === 0) return "";
46
+ let bestStart = matches[0].index;
47
+ let bestCoverage = -1;
48
+ for (const match of matches) {
49
+ const start = Math.max(0, match.index - Math.floor(cap / 5));
50
+ const coverage = keywordRelevance(query, text.slice(start, start + cap));
51
+ if (coverage > bestCoverage) {
52
+ bestStart = start;
53
+ bestCoverage = coverage;
54
+ }
55
+ }
56
+ if (bestStart > 0) {
57
+ const boundary = text.indexOf(" ", bestStart);
58
+ if (boundary >= 0 && boundary < matches[matches.length - 1].index) bestStart = boundary + 1;
59
+ }
60
+ const passage = text.slice(bestStart, bestStart + cap).trim();
61
+ return `${bestStart > 0 ? "…" : ""}${passage}${bestStart + cap < text.length ? "…" : ""}`;
62
+ }
@@ -0,0 +1,291 @@
1
+ import { expect, test } from "bun:test";
2
+ import { rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ Database,
7
+ guardSqliteTransformPass,
8
+ SqliteAcquisitionBusyError,
9
+ withSqliteBackgroundWriter,
10
+ withSqliteTransformPass,
11
+ } from "./sqlite";
12
+ import { createTestTempDirFromPath } from "./test-temp-dir";
13
+
14
+ function fixture() {
15
+ const dir = createTestTempDirFromPath(join(tmpdir(), "mc-busy-review-"));
16
+ const path = join(dir, "context.db");
17
+ const db = new Database(path);
18
+ db.exec("PRAGMA busy_timeout=5000; CREATE TABLE result(id INTEGER PRIMARY KEY, n INTEGER)");
19
+ db.exec("INSERT INTO result VALUES (1, 0)");
20
+ return {
21
+ db,
22
+ path,
23
+ close() {
24
+ db.close();
25
+ rmSync(dir, { recursive: true, force: true });
26
+ },
27
+ };
28
+ }
29
+
30
+ async function lock(path: string, mode: "read" | "write" | "exclusive", holdMs: number) {
31
+ const child = Bun.spawn(
32
+ [
33
+ "bun",
34
+ "--eval",
35
+ `import { Database } from "bun:sqlite";
36
+ const db = new Database(process.argv[1]);
37
+ db.exec(process.argv[2] === "read" ? "BEGIN; SELECT * FROM result"
38
+ : process.argv[2] === "write" ? "BEGIN IMMEDIATE" : "BEGIN EXCLUSIVE");
39
+ console.log("LOCKED");
40
+ Bun.sleepSync(Number(process.argv[3]));
41
+ db.exec("COMMIT");
42
+ db.close();`,
43
+ path,
44
+ mode,
45
+ String(holdMs),
46
+ ],
47
+ { stdout: "pipe", stderr: "pipe", windowsHide: true },
48
+ );
49
+ const reader = child.stdout.getReader();
50
+ let output = "";
51
+ while (!output.includes("LOCKED\n")) {
52
+ const chunk = await reader.read();
53
+ if (chunk.done) {
54
+ throw new Error(`locker failed: ${await new Response(child.stderr).text()}`);
55
+ }
56
+ output += new TextDecoder().decode(chunk.value);
57
+ }
58
+ reader.releaseLock();
59
+ return { exited: child.exited };
60
+ }
61
+
62
+ for (const method of ["run", "get", "all", "exec"] as const) {
63
+ test(`a rollback-journal commit BUSY retries ${method} exactly once after the reader leaves`, async () => {
64
+ const { db, path, close } = fixture();
65
+ const sql = "UPDATE result SET n = n + 1 WHERE id = 1";
66
+ const statement = db.prepare(`${sql} RETURNING n`);
67
+ const { exited } = await lock(path, "read", 150);
68
+ try {
69
+ await withSqliteTransformPass(() => {
70
+ if (method === "exec") db.exec(sql);
71
+ else statement[method]();
72
+ });
73
+ expect(db.prepare("SELECT * FROM result").all()).toEqual([{ id: 1, n: 1 }]);
74
+ expect(db.inTransaction).toBe(false);
75
+ } finally {
76
+ expect(await exited).toBe(0);
77
+ close();
78
+ }
79
+ });
80
+ }
81
+
82
+ for (const scope of ["foreground", "background", "unscoped"] as const) {
83
+ test(`exec in ${scope} does not wait on an unrelated attached database schema`, async () => {
84
+ const main = fixture();
85
+ const aux = fixture();
86
+ main.db.prepare("ATTACH DATABASE ? AS aux").run(aux.path);
87
+ const { exited } = await lock(aux.path, "exclusive", 2500);
88
+ try {
89
+ const started = performance.now();
90
+ const operation = () => main.db.exec("UPDATE main.result SET n = n + 1");
91
+ if (scope === "foreground") {
92
+ await withSqliteTransformPass(() => {
93
+ guardSqliteTransformPass({ assert() {}, remainingMs: () => 75 });
94
+ operation();
95
+ });
96
+ } else if (scope === "background") withSqliteBackgroundWriter(operation);
97
+ else operation();
98
+ expect(performance.now() - started).toBeLessThan(1500);
99
+ expect(main.db.prepare("SELECT n FROM main.result").get()).toEqual({ n: 1 });
100
+ } finally {
101
+ expect(await exited).toBe(0);
102
+ main.close();
103
+ aux.close();
104
+ }
105
+ }, 10_000);
106
+ }
107
+
108
+ for (const location of ["main", "aux"] as const) {
109
+ test(`BUSY retries leave single-statement effects atomic in ${location}, including AFTER triggers`, async () => {
110
+ const main = fixture();
111
+ const aux = fixture();
112
+ main.db.prepare("ATTACH DATABASE ? AS aux").run(aux.path);
113
+ main.db.exec("CREATE TABLE audit(n INTEGER)");
114
+ main.db.exec(`CREATE TEMP TRIGGER audit_insert AFTER INSERT ON ${location}.result
115
+ BEGIN INSERT INTO audit VALUES (new.n); END`);
116
+ main.db.exec(`CREATE TEMP TRIGGER audit_update AFTER UPDATE ON ${location}.result
117
+ BEGIN INSERT INTO audit VALUES (new.n); END`);
118
+ main.db.exec(`CREATE TEMP TRIGGER audit_delete AFTER DELETE ON ${location}.result
119
+ BEGIN INSERT INTO audit VALUES (old.n); END`);
120
+ const forms = [
121
+ `INSERT INTO ${location}.result VALUES (2, 10)`,
122
+ `INSERT OR IGNORE INTO ${location}.result VALUES (2, 20)`,
123
+ `INSERT OR REPLACE INTO ${location}.result VALUES (2, 30)`,
124
+ `INSERT INTO ${location}.result VALUES (2, 40) ON CONFLICT(id) DO UPDATE SET n=n+1`,
125
+ `UPDATE ${location}.result SET n=n+1 WHERE id=2`,
126
+ `DELETE FROM ${location}.result WHERE id=2`,
127
+ ];
128
+ try {
129
+ for (const sql of forms) {
130
+ const statement = main.db.prepare(`${sql} RETURNING *`);
131
+ const blocker = new Database(location === "main" ? main.path : aux.path);
132
+ blocker.exec("BEGIN IMMEDIATE");
133
+ main.db.exec("PRAGMA busy_timeout=0");
134
+ const before = main.db
135
+ .prepare(`SELECT * FROM ${location}.result ORDER BY id`)
136
+ .all();
137
+ const beforeAudit = main.db.prepare("SELECT * FROM audit").all();
138
+ try {
139
+ expect(() => statement.all()).toThrow("locked");
140
+ expect(
141
+ main.db.prepare(`SELECT * FROM ${location}.result ORDER BY id`).all(),
142
+ ).toEqual(before);
143
+ expect(main.db.prepare("SELECT * FROM audit").all()).toEqual(beforeAudit);
144
+ expect(main.db.inTransaction).toBe(false);
145
+ } finally {
146
+ blocker.exec("ROLLBACK");
147
+ blocker.close();
148
+ }
149
+ const { exited } = await lock(
150
+ location === "main" ? main.path : aux.path,
151
+ "write",
152
+ 60,
153
+ );
154
+ try {
155
+ await withSqliteTransformPass(() => statement.all());
156
+ } finally {
157
+ expect(await exited).toBe(0);
158
+ }
159
+ }
160
+ expect(main.db.prepare(`SELECT * FROM ${location}.result ORDER BY id`).all()).toEqual([
161
+ { id: 1, n: 0 },
162
+ ]);
163
+ expect(main.db.prepare("SELECT * FROM audit").all()).toEqual([
164
+ { n: 10 },
165
+ { n: 30 },
166
+ { n: 31 },
167
+ { n: 32 },
168
+ { n: 32 },
169
+ ]);
170
+ } finally {
171
+ main.close();
172
+ aux.close();
173
+ }
174
+ }, 10_000);
175
+ }
176
+
177
+ test("an attached DML-only committed prefix is not replayed after a later BUSY", () => {
178
+ const main = fixture();
179
+ const free = fixture();
180
+ const blocked = fixture();
181
+ try {
182
+ main.db.prepare("ATTACH DATABASE ? AS free").run(free.path);
183
+ main.db.prepare("ATTACH DATABASE ? AS blocked").run(blocked.path);
184
+ blocked.db.exec("BEGIN IMMEDIATE");
185
+ withSqliteTransformPass(() => {
186
+ expect(() =>
187
+ main.db.exec("UPDATE free.result SET n=n+1; UPDATE blocked.result SET n=n+1"),
188
+ ).toThrow(SqliteAcquisitionBusyError);
189
+ });
190
+ expect(main.db.prepare("SELECT n FROM free.result").get()).toEqual({ n: 1 });
191
+ expect(main.db.prepare("SELECT n FROM blocked.result").get()).toEqual({ n: 0 });
192
+ } finally {
193
+ blocked.db.exec("ROLLBACK");
194
+ main.close();
195
+ free.close();
196
+ blocked.close();
197
+ }
198
+ });
199
+
200
+ for (const location of ["main", "free"] as const) {
201
+ test(`an ignored AUTOINCREMENT prefix in ${location} is committed only once before a later BUSY`, () => {
202
+ const main = fixture();
203
+ const free = fixture();
204
+ const blocked = fixture();
205
+ try {
206
+ main.db.prepare("ATTACH DATABASE ? AS free").run(free.path);
207
+ main.db.exec(`CREATE TABLE ${location}.numbered(id INTEGER PRIMARY KEY AUTOINCREMENT, value TEXT UNIQUE);
208
+ INSERT INTO ${location}.numbered(value) VALUES ('existing')`);
209
+ main.db.prepare("ATTACH DATABASE ? AS blocked").run(blocked.path);
210
+ blocked.db.exec("BEGIN IMMEDIATE");
211
+ const before = main.db.prepare("SELECT total_changes() AS n").get();
212
+ withSqliteTransformPass(() => {
213
+ guardSqliteTransformPass({ assert() {}, remainingMs: () => 50 });
214
+ expect(() =>
215
+ main.db.exec(
216
+ `INSERT OR IGNORE INTO ${location}.numbered(value) VALUES ('existing'); UPDATE blocked.result SET n=n+1`,
217
+ ),
218
+ ).toThrow(SqliteAcquisitionBusyError);
219
+ });
220
+ expect(main.db.prepare("SELECT total_changes() AS n").get()).toEqual(before);
221
+ expect(
222
+ main.db
223
+ .prepare(`SELECT seq FROM ${location}.sqlite_sequence WHERE name='numbered'`)
224
+ .get(),
225
+ ).toEqual({ seq: 2 });
226
+ expect(main.db.prepare(`SELECT * FROM ${location}.numbered`).all()).toEqual([
227
+ { id: 1, value: "existing" },
228
+ ]);
229
+ } finally {
230
+ blocked.db.exec("ROLLBACK");
231
+ main.close();
232
+ free.close();
233
+ blocked.close();
234
+ }
235
+ });
236
+ }
237
+
238
+ test("a WAL BUSY_SNAPSHOT in an explicit read transaction is not retried", () => {
239
+ const { db, path, close } = fixture();
240
+ db.exec("PRAGMA journal_mode=WAL");
241
+ const other = new Database(path);
242
+ try {
243
+ db.exec("BEGIN; SELECT * FROM result");
244
+ other.exec("UPDATE result SET n=n+1");
245
+ withSqliteTransformPass(() => {
246
+ expect(() => db.prepare("UPDATE result SET n=n+10").run()).toThrow("locked");
247
+ });
248
+ expect(db.inTransaction).toBe(true);
249
+ db.exec("ROLLBACK");
250
+ expect(db.prepare("SELECT n FROM result").get()).toEqual({ n: 1 });
251
+ } finally {
252
+ if (db.inTransaction) db.exec("ROLLBACK");
253
+ other.close();
254
+ close();
255
+ }
256
+ });
257
+
258
+ test("a BUSY from an AFTER trigger writing an attached database rolls back the outer statement", async () => {
259
+ const main = fixture();
260
+ const aux = fixture();
261
+ main.db.prepare("ATTACH DATABASE ? AS aux").run(aux.path);
262
+ // The TEMP trigger resolves an unqualified table in main first; give the
263
+ // attached target its own name so the trigger must write the other file.
264
+ main.db.exec("ALTER TABLE aux.result RENAME TO attached_result");
265
+ main.db.exec(`CREATE TEMP TRIGGER update_aux AFTER UPDATE ON main.result
266
+ BEGIN UPDATE attached_result SET n=n+1 WHERE id=1; END`);
267
+ const statement = main.db.prepare("UPDATE main.result SET n=n+1 RETURNING n");
268
+ const blocker = new Database(aux.path);
269
+ try {
270
+ blocker.exec("BEGIN IMMEDIATE");
271
+ main.db.exec("PRAGMA busy_timeout=0");
272
+ expect(() => statement.all()).toThrow("locked");
273
+ expect(main.db.prepare("SELECT n FROM main.result").get()).toEqual({ n: 0 });
274
+ expect(main.db.prepare("SELECT n FROM aux.attached_result").get()).toEqual({ n: 0 });
275
+ expect(main.db.inTransaction).toBe(false);
276
+ blocker.exec("ROLLBACK");
277
+ const { exited } = await lock(aux.path, "write", 100);
278
+ try {
279
+ await withSqliteTransformPass(() => statement.all());
280
+ } finally {
281
+ expect(await exited).toBe(0);
282
+ }
283
+ expect(main.db.prepare("SELECT n FROM main.result").get()).toEqual({ n: 1 });
284
+ expect(main.db.prepare("SELECT n FROM aux.attached_result").get()).toEqual({ n: 1 });
285
+ } finally {
286
+ if (blocker.inTransaction) blocker.exec("ROLLBACK");
287
+ blocker.close();
288
+ main.close();
289
+ aux.close();
290
+ }
291
+ });