oh-my-opencode 4.18.0 → 4.18.2

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 (226) hide show
  1. package/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3677 -0
  2. package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3154 -0
  3. package/.agents/skills/work-with-pr/SKILL.md +16 -37
  4. package/.agents/skills/work-with-pr-workspace/evals/evals.json +3 -3
  5. package/.opencode/skills/work-with-pr/SKILL.md +16 -37
  6. package/.opencode/skills/work-with-pr-workspace/evals/evals.json +3 -3
  7. package/bin/AGENTS.md +33 -0
  8. package/dist/cli/index.js +500 -158
  9. package/dist/cli-node/index.js +500 -158
  10. package/dist/hooks/anthropic-context-window-limit-recovery/empty-content-recovery-sdk.d.ts +6 -0
  11. package/dist/hooks/category-skill-reminder/hook.d.ts +9 -1
  12. package/dist/hooks/comment-checker/hook.d.ts +8 -1
  13. package/dist/hooks/todo-continuation-enforcer/types.d.ts +3 -0
  14. package/dist/index.js +956 -702
  15. package/dist/plugin/messages-transform.d.ts +1 -0
  16. package/dist/plugin-handlers/prometheus-agent-config-builder.d.ts +2 -0
  17. package/dist/tui.js +43 -4
  18. package/package.json +16 -16
  19. package/packages/git-bash-mcp/dist/cli.js +81 -19
  20. package/packages/lsp-core/package.json +4 -0
  21. package/packages/lsp-core/src/index.ts +1 -0
  22. package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
  23. package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
  24. package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
  25. package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
  26. package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
  27. package/packages/lsp-core/src/lsp/client.ts +262 -80
  28. package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
  29. package/packages/lsp-core/src/lsp/connection.ts +12 -6
  30. package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +221 -0
  31. package/packages/lsp-core/src/lsp/directory-diagnostics.ts +61 -28
  32. package/packages/lsp-core/src/lsp/errors.ts +11 -0
  33. package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
  34. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
  35. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
  36. package/packages/lsp-core/src/lsp/formatters.ts +3 -0
  37. package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
  38. package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
  39. package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
  40. package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
  41. package/packages/lsp-core/src/lsp/transport.ts +96 -70
  42. package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
  43. package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
  44. package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
  45. package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
  46. package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
  47. package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
  48. package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
  49. package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
  50. package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
  51. package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
  52. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
  53. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
  54. package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
  55. package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
  56. package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
  57. package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
  58. package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
  59. package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
  60. package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
  61. package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
  62. package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
  63. package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
  64. package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
  65. package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
  66. package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
  67. package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
  68. package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
  69. package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
  70. package/packages/lsp-core/src/mcp.ts +18 -7
  71. package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
  72. package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
  73. package/packages/lsp-core/src/post-edit/index.ts +1 -0
  74. package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
  75. package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
  76. package/packages/lsp-core/src/request-context.test.ts +171 -0
  77. package/packages/lsp-core/src/request-context.ts +222 -9
  78. package/packages/lsp-core/src/tool-surface.test.ts +4 -1
  79. package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
  80. package/packages/lsp-core/src/tools/navigation.ts +12 -12
  81. package/packages/lsp-core/src/tools/rename.ts +10 -15
  82. package/packages/lsp-core/src/tools/symbols.ts +11 -11
  83. package/packages/lsp-core/src/tools/types.ts +2 -1
  84. package/packages/lsp-daemon/dist/cli.js +3330 -764
  85. package/packages/lsp-daemon/dist/client.d.ts +105 -0
  86. package/packages/lsp-daemon/dist/client.js +5995 -0
  87. package/packages/lsp-daemon/dist/daemon-client.d.ts +12 -7
  88. package/packages/lsp-daemon/dist/daemon-client.js +139 -32
  89. package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
  90. package/packages/lsp-daemon/dist/daemon-server.js +40 -15
  91. package/packages/lsp-daemon/dist/ensure-daemon.d.ts +10 -8
  92. package/packages/lsp-daemon/dist/ensure-daemon.js +135 -51
  93. package/packages/lsp-daemon/dist/index.d.ts +2 -2
  94. package/packages/lsp-daemon/dist/index.js +3093 -786
  95. package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
  96. package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
  97. package/packages/lsp-daemon/dist/lock.js +14 -4
  98. package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
  99. package/packages/lsp-daemon/dist/ownership.js +168 -0
  100. package/packages/lsp-daemon/dist/paths.d.ts +33 -9
  101. package/packages/lsp-daemon/dist/paths.js +72 -33
  102. package/packages/lsp-daemon/dist/proxy.d.ts +5 -0
  103. package/packages/lsp-daemon/dist/proxy.js +123 -16
  104. package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
  105. package/packages/lsp-daemon/dist/request-routing.js +71 -22
  106. package/packages/lsp-daemon/dist/run-daemon.js +9 -2
  107. package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
  108. package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
  109. package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
  110. package/packages/lsp-daemon/package.json +12 -3
  111. package/packages/lsp-tools-mcp/dist/cli.js +2189 -454
  112. package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
  113. package/packages/lsp-tools-mcp/dist/mcp.js +2206 -471
  114. package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
  115. package/packages/lsp-tools-mcp/dist/tools.js +2119 -447
  116. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  117. package/packages/omo-codex/plugin/.mcp.json +2 -1
  118. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  119. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  120. package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +100 -28
  121. package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +100 -28
  122. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  123. package/packages/omo-codex/plugin/components/codegraph/src/mcp-bridge.ts +21 -9
  124. package/packages/omo-codex/plugin/components/codegraph/test/mcp-bridge-fixtures.ts +35 -0
  125. package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge-lifecycle.test.ts +69 -0
  126. package/packages/omo-codex/plugin/components/codegraph/test/serve-mcp-bridge.test.ts +57 -1
  127. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  128. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  129. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  130. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  131. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  132. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  133. package/packages/omo-codex/plugin/components/lsp/.mcp.json +2 -1
  134. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
  135. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +3033 -936
  136. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
  137. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
  138. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
  139. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
  140. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
  141. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
  142. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
  143. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  144. package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
  145. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
  146. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
  147. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
  148. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
  149. package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
  150. package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
  151. package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
  152. package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
  153. package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
  154. package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
  155. package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +20 -5
  156. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
  157. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -3
  158. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  159. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  160. package/packages/omo-codex/plugin/components/start-work-continuation/README.md +2 -2
  161. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +3 -3
  162. package/packages/omo-codex/plugin/components/start-work-continuation/dist/cli.js +2 -2
  163. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  164. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  165. package/packages/omo-codex/plugin/components/start-work-continuation/src/boulder-reader.ts +1 -1
  166. package/packages/omo-codex/plugin/components/start-work-continuation/src/codex-hook.ts +1 -1
  167. package/packages/omo-codex/plugin/components/start-work-continuation/test/boulder-reader.test.ts +15 -1
  168. package/packages/omo-codex/plugin/components/start-work-continuation/test/codex-hook.test.ts +20 -0
  169. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  170. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  171. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  172. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  173. package/packages/omo-codex/plugin/components/ultrawork/directive.md +50 -33
  174. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  175. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  176. package/packages/omo-codex/plugin/components/ultrawork/skills/ultrawork/SKILL.md +50 -33
  177. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +50 -33
  178. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +3 -3
  179. package/packages/omo-codex/plugin/components/ulw-loop/dist/stop-resume-hook.js +5 -6
  180. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  181. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  182. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
  183. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +6 -5
  184. package/packages/omo-codex/plugin/components/ulw-loop/src/stop-resume-hook.ts +5 -6
  185. package/packages/omo-codex/plugin/components/ulw-loop/test/stop-resume-hook.test.ts +2 -2
  186. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  187. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  188. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  189. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  190. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  191. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  192. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  193. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  194. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  195. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  196. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  197. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  198. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  199. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  200. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  201. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  202. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  203. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  204. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  205. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  206. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  207. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  208. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  209. package/packages/omo-codex/plugin/package-lock.json +26 -14
  210. package/packages/omo-codex/plugin/package.json +1 -1
  211. package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
  212. package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
  213. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +9 -1
  214. package/packages/omo-codex/plugin/skills/review-work/SKILL.md +7 -0
  215. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +2 -1
  216. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +50 -33
  217. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
  218. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +6 -5
  219. package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
  220. package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
  221. package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
  222. package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
  223. package/packages/omo-codex/plugin/test/mcp-research-servers.test.mjs +1 -0
  224. package/packages/omo-codex/plugin/test/sync-skills-orchestration.test.mjs +7 -0
  225. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +34 -3
  226. package/packages/omo-codex/scripts/install-dist/install-local.mjs +328 -63
@@ -0,0 +1,196 @@
1
+ import { readFileSync, realpathSync, writeFileSync } from "node:fs";
2
+ import { pathToFileURL } from "node:url";
3
+
4
+ import {
5
+ createWorkspaceEditTestHarness,
6
+ diagnostic,
7
+ readEvents,
8
+ renameTextEdit,
9
+ waitForEventCount,
10
+ } from "../workspace-apply-edit-test-support.js";
11
+ import { workspaceEditContractFailureEvidence } from "../workspace-edit-contract-evidence.js";
12
+
13
+ class WorkspaceEditContractProbeError extends Error {
14
+ override readonly name = "WorkspaceEditContractProbeError";
15
+ }
16
+
17
+ function requireProbe(condition: boolean, message: string): asserts condition {
18
+ if (!condition) throw new WorkspaceEditContractProbeError(message);
19
+ }
20
+
21
+ function isRecord(value: unknown): value is Record<string, unknown> {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+
25
+ function applyResponses(eventsPath: string): readonly unknown[] {
26
+ return readEvents(eventsPath)
27
+ .filter((event) => event.type === "clientResponse" && event.method === "workspace/applyEdit")
28
+ .map((event) => event.result);
29
+ }
30
+
31
+ function isRejectedApply(value: unknown): boolean {
32
+ return isRecord(value) && value["applied"] === false && typeof value["failureReason"] === "string";
33
+ }
34
+
35
+ function didChangeVersions(eventsPath: string): readonly number[] {
36
+ const versions: number[] = [];
37
+ for (const event of readEvents(eventsPath)) {
38
+ if (event.type !== "clientNotification" || event.method !== "textDocument/didChange") continue;
39
+ if (!isRecord(event.params)) continue;
40
+ const textDocument = event.params["textDocument"];
41
+ if (!isRecord(textDocument) || typeof textDocument["version"] !== "number") continue;
42
+ versions.push(textDocument["version"]);
43
+ }
44
+ return versions;
45
+ }
46
+
47
+ const outputPath = process.argv[2];
48
+ if (!outputPath) throw new WorkspaceEditContractProbeError("output path is required");
49
+
50
+ const harness = createWorkspaceEditTestHarness();
51
+ try {
52
+ const success = await harness.makeClient({
53
+ renameSteps: [{ applyEdit: renameTextEdit("before", "after", { version: 1 }), renameResult: "same" }],
54
+ diagnostics: [diagnostic("todo3-fresh")],
55
+ });
56
+ let successWrites = 0;
57
+ success.client.setWorkspaceEditIo({
58
+ writeFile(path, content) {
59
+ successWrites += 1;
60
+ writeFileSync(path, content, "utf-8");
61
+ },
62
+ });
63
+ await success.client.openFile(success.source);
64
+ const successResult = await success.client.rename(success.source, 1, 6, "after");
65
+ const successResponses = applyResponses(success.events);
66
+ const successVersions = didChangeVersions(success.events);
67
+ const successUri = pathToFileURL(realpathSync(success.source)).href;
68
+ const successEvidence = {
69
+ serverAppliedRename:
70
+ successResponses.length === 1 && isRecord(successResponses[0]) && successResponses[0]["applied"] === true,
71
+ recordedResultReused: successResult.edit !== null && successResult.apply.success && successWrites === 1,
72
+ writeCount: successWrites,
73
+ secondWritePrevented: successWrites === 1 && successVersions.length === 1,
74
+ synchronizedDocumentVersion: success.client.getOpenDocumentVersion(success.source) ?? null,
75
+ didChangeVersions: successVersions,
76
+ immediateDiagnostics: success.client
77
+ .getStoredDiagnostics(successUri)
78
+ .some((entry) => entry.message === "todo3-fresh"),
79
+ finalContent: readFileSync(success.source, "utf-8"),
80
+ };
81
+ requireProbe(successEvidence.serverAppliedRename, "successful server apply was not reported truthfully");
82
+ requireProbe(successEvidence.recordedResultReused, "rename did not reuse the recorded server result");
83
+ requireProbe(successEvidence.secondWritePrevented, "server edit was written or synchronized more than once");
84
+ requireProbe(successEvidence.synchronizedDocumentVersion === 2, "open document version did not advance to 2");
85
+ requireProbe(successEvidence.immediateDiagnostics, "fresh diagnostics were not visible immediately after applyEdit");
86
+ requireProbe(successEvidence.finalContent === "const after = 1;\n", "successful rename produced unexpected bytes");
87
+
88
+ const unscoped = await harness.makeClient({
89
+ unscopedApplyEdit: renameTextEdit("before", "after", { version: 1 }),
90
+ });
91
+ const unscopedResponses = await waitForEventCount(
92
+ unscoped.events,
93
+ (event) => event.type === "clientResponse" && event.method === "workspace/applyEdit",
94
+ 1,
95
+ );
96
+ const unscopedFailure = unscopedResponses[0]?.result;
97
+ requireProbe(isRejectedApply(unscopedFailure), "unscoped applyEdit was not rejected");
98
+ requireProbe(readFileSync(unscoped.source, "utf-8") === "const before = 1;\n", "unscoped failure mutated bytes");
99
+
100
+ const concurrent = await harness.makeClient({
101
+ renameSteps: [
102
+ {
103
+ applyEdit: renameTextEdit("before", "after", { version: 1 }),
104
+ applyConcurrently: true,
105
+ renameResult: "same",
106
+ },
107
+ ],
108
+ });
109
+ let concurrentWrites = 0;
110
+ concurrent.client.setWorkspaceEditIo({
111
+ writeFile(path, content) {
112
+ concurrentWrites += 1;
113
+ writeFileSync(path, content, "utf-8");
114
+ },
115
+ });
116
+ await concurrent.client.openFile(concurrent.source);
117
+ const concurrentResult = await concurrent.client.rename(concurrent.source, 1, 6, "after");
118
+ const concurrentFailure = applyResponses(concurrent.events).find(isRejectedApply);
119
+ requireProbe(concurrentResult.apply.success, "the winning concurrent applyEdit did not complete");
120
+ requireProbe(concurrentWrites === 1, "concurrent applyEdit wrote more than once");
121
+ requireProbe(isRejectedApply(concurrentFailure), "concurrent applyEdit loser was not rejected");
122
+
123
+ const mismatched = await harness.makeClient({
124
+ renameSteps: [{ applyEdit: renameTextEdit("before", "after", { version: 9 }), renameResult: "same" }],
125
+ });
126
+ await mismatched.client.openFile(mismatched.source);
127
+ const mismatchedResult = await mismatched.client.rename(mismatched.source, 1, 6, "after");
128
+ const mismatchedFailure = applyResponses(mismatched.events).find(isRejectedApply);
129
+ requireProbe(!mismatchedResult.apply.success, "mismatched document version unexpectedly succeeded");
130
+ requireProbe(isRejectedApply(mismatchedFailure), "mismatched version was not rejected over applyEdit");
131
+ requireProbe(readFileSync(mismatched.source, "utf-8") === "const before = 1;\n", "version failure mutated bytes");
132
+
133
+ const preGate = await harness.makeClient({
134
+ renameSteps: [
135
+ {
136
+ applyEdit: renameTextEdit("before", "after", { version: 1 }),
137
+ applyDelayMs: 100,
138
+ renameResult: "same",
139
+ },
140
+ ],
141
+ });
142
+ const controller = new AbortController();
143
+ await preGate.client.openFile(preGate.source);
144
+ const pendingRename = preGate.client.rename(preGate.source, 1, 6, "after", controller.signal);
145
+ await waitForEventCount(
146
+ preGate.events,
147
+ (event) => event.type === "clientRequest" && event.method === "textDocument/rename",
148
+ 1,
149
+ );
150
+ controller.abort();
151
+ const preGateError = await pendingRename.then(
152
+ () => null,
153
+ (error: unknown) => error,
154
+ );
155
+ const preGateMessage = preGateError instanceof Error ? preGateError.message : String(preGateError);
156
+ const preGateFailure = {
157
+ success: false,
158
+ filesModified: [],
159
+ totalEdits: 0,
160
+ errors: [preGateMessage],
161
+ };
162
+ requireProbe(preGateError !== null, "pre-gate cancellation unexpectedly succeeded");
163
+ requireProbe(
164
+ preGateFailure.errors.some((message) => /cancel|abort/i.test(message)),
165
+ "pre-gate cancellation returned the wrong failure",
166
+ );
167
+ requireProbe(readFileSync(preGate.source, "utf-8") === "const before = 1;\n", "pre-gate cancellation mutated bytes");
168
+
169
+ const failureCases = {
170
+ unscoped: workspaceEditContractFailureEvidence(unscopedFailure, unscoped.workspace),
171
+ concurrent: workspaceEditContractFailureEvidence(concurrentFailure, concurrent.workspace),
172
+ mismatched: workspaceEditContractFailureEvidence(mismatchedFailure, mismatched.workspace),
173
+ preGate: workspaceEditContractFailureEvidence(preGateFailure, preGate.workspace),
174
+ };
175
+ writeFileSync(
176
+ outputPath,
177
+ `${JSON.stringify(
178
+ {
179
+ schemaVersion: 1,
180
+ success: successEvidence,
181
+ failureCases,
182
+ failureHashes: {
183
+ unscoped: failureCases.unscoped.sha256,
184
+ concurrent: failureCases.concurrent.sha256,
185
+ mismatched: failureCases.mismatched.sha256,
186
+ preGate: failureCases.preGate.sha256,
187
+ },
188
+ },
189
+ null,
190
+ 2,
191
+ )}\n`,
192
+ "utf-8",
193
+ );
194
+ } finally {
195
+ await harness.cleanup();
196
+ }
@@ -0,0 +1,215 @@
1
+ import { appendFileSync, readFileSync } from "node:fs";
2
+
3
+ const [, , scenarioPath, eventsPath] = process.argv;
4
+ const scenario = JSON.parse(readFileSync(scenarioPath, "utf-8"));
5
+
6
+ let buffer = Buffer.alloc(0);
7
+ let nextServerRequestId = 10_000;
8
+ let renameIndex = 0;
9
+ let diagnosticIndex = 0;
10
+ const pendingServerRequests = new Map();
11
+
12
+ function record(event) {
13
+ appendFileSync(eventsPath, `${JSON.stringify(event)}\n`, "utf-8");
14
+ }
15
+
16
+ function send(message) {
17
+ const body = Buffer.from(JSON.stringify(message), "utf-8");
18
+ process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`);
19
+ process.stdout.write(body);
20
+ }
21
+
22
+ function sendResponse(id, result) {
23
+ send({ jsonrpc: "2.0", id, result });
24
+ }
25
+
26
+ function sendError(id, code, message) {
27
+ send({ jsonrpc: "2.0", id, error: { code, message } });
28
+ }
29
+
30
+ function sendApplyEdit(edit, context) {
31
+ const id = nextServerRequestId;
32
+ nextServerRequestId += 1;
33
+ pendingServerRequests.set(String(id), context);
34
+ record({ type: "serverRequest", method: "workspace/applyEdit", id, edit });
35
+ send({ jsonrpc: "2.0", id, method: "workspace/applyEdit", params: { edit } });
36
+ }
37
+
38
+ function renameResult(step) {
39
+ if (step.renameResult === "same") return step.applyEdit ?? null;
40
+ if (step.renameResult === "different") return step.differentEdit ?? null;
41
+ if (step.renameResult === "edit") return step.renameEdit ?? null;
42
+ return null;
43
+ }
44
+
45
+ function finishRename(context) {
46
+ const result = renameResult(context.step);
47
+ const delay = Number(context.step.responseDelayMs ?? 0);
48
+ setTimeout(() => {
49
+ record({ type: "serverResponse", method: "textDocument/rename", result });
50
+ sendResponse(context.renameRequestId, result);
51
+ }, delay);
52
+ }
53
+
54
+ function schedulePublish(step, fallbackUri, fallbackVersion) {
55
+ const delay = Number(step.delayMs ?? 0);
56
+ setTimeout(() => {
57
+ const params = {
58
+ uri: step.uri ?? fallbackUri,
59
+ ...(step.version === undefined ? {} : { version: step.version }),
60
+ diagnostics: Array.isArray(step.diagnostics) ? step.diagnostics : [],
61
+ };
62
+ record({ type: "serverNotification", method: "textDocument/publishDiagnostics", params });
63
+ send({ jsonrpc: "2.0", method: "textDocument/publishDiagnostics", params });
64
+ }, delay);
65
+ }
66
+
67
+ function publishSteps(trigger) {
68
+ if (!Array.isArray(scenario.publishDiagnostics)) return [];
69
+ return scenario.publishDiagnostics.filter((step) => step?.trigger === trigger);
70
+ }
71
+
72
+ function applyPublishSteps(trigger, fallbackUri, fallbackVersion) {
73
+ for (const step of publishSteps(trigger)) schedulePublish(step, fallbackUri, fallbackVersion);
74
+ }
75
+
76
+ function handleClientResponse(message) {
77
+ const context = pendingServerRequests.get(String(message.id));
78
+ if (!context) return;
79
+ pendingServerRequests.delete(String(message.id));
80
+ record({
81
+ type: "clientResponse",
82
+ method: "workspace/applyEdit",
83
+ result: message.result ?? null,
84
+ error: message.error ?? null,
85
+ });
86
+ if (context.kind === "unscoped") return;
87
+ if (typeof context.pendingApplyResponses === "number") {
88
+ context.pendingApplyResponses -= 1;
89
+ if (context.pendingApplyResponses > 0) return;
90
+ finishRename(context);
91
+ return;
92
+ }
93
+ if (context.step.applyTwice === true && context.applyCount === 1) {
94
+ sendApplyEdit(context.step.applyEdit, { ...context, applyCount: 2 });
95
+ return;
96
+ }
97
+ finishRename(context);
98
+ }
99
+
100
+ function handleRequest(message) {
101
+ if (message.method === "initialize") {
102
+ record({ type: "clientRequest", method: message.method, id: message.id, params: message.params });
103
+ sendResponse(message.id, { capabilities: scenario.capabilities ?? {} });
104
+ return;
105
+ }
106
+ if (message.method === "textDocument/rename") {
107
+ const step = scenario.renameSteps?.[renameIndex] ?? {};
108
+ renameIndex += 1;
109
+ record({ type: "clientRequest", method: message.method, id: message.id, params: message.params, renameIndex });
110
+ if (step.applyEdit) {
111
+ const context = { kind: "rename", renameRequestId: message.id, step, applyCount: 1 };
112
+ if (step.applyConcurrently === true) {
113
+ context.pendingApplyResponses = 2;
114
+ sendApplyEdit(step.applyEdit, context);
115
+ sendApplyEdit(step.applyEdit, context);
116
+ return;
117
+ }
118
+ const delay = Number(step.applyDelayMs ?? 0);
119
+ if (delay > 0) {
120
+ setTimeout(() => sendApplyEdit(step.applyEdit, context), delay);
121
+ return;
122
+ }
123
+ sendApplyEdit(step.applyEdit, context);
124
+ return;
125
+ }
126
+ finishRename({ renameRequestId: message.id, step });
127
+ return;
128
+ }
129
+ if (message.method === "textDocument/diagnostic") {
130
+ record({ type: "clientRequest", method: message.method, id: message.id, params: message.params, diagnosticIndex: diagnosticIndex + 1 });
131
+ applyPublishSteps("diagnosticRequest", message.params?.textDocument?.uri, undefined);
132
+ const step = Array.isArray(scenario.diagnosticResponses) ? scenario.diagnosticResponses[diagnosticIndex] : undefined;
133
+ diagnosticIndex += 1;
134
+ if (step?.action === "hang") return;
135
+ if (step?.action === "close") {
136
+ const delay = Number(step.delayMs ?? 0);
137
+ setTimeout(() => process.exit(0), delay);
138
+ return;
139
+ }
140
+ const delay = Number(step?.delayMs ?? 0);
141
+ setTimeout(() => {
142
+ if (step?.error) {
143
+ record({ type: "serverError", method: "textDocument/diagnostic", error: step.error });
144
+ sendError(message.id, step.error.code, step.error.message);
145
+ return;
146
+ }
147
+ const result = step?.report ?? { items: scenario.diagnostics ?? [] };
148
+ record({ type: "serverResponse", method: "textDocument/diagnostic", result });
149
+ sendResponse(message.id, result);
150
+ }, delay);
151
+ return;
152
+ }
153
+ if (message.method === "shutdown") {
154
+ sendResponse(message.id, null);
155
+ return;
156
+ }
157
+ sendResponse(message.id, null);
158
+ }
159
+
160
+ function handleNotification(message) {
161
+ record({ type: "clientNotification", method: message.method, params: message.params ?? null });
162
+ if (message.method === "initialized" && scenario.unscopedApplyEdit) {
163
+ sendApplyEdit(scenario.unscopedApplyEdit, { kind: "unscoped" });
164
+ }
165
+ if (message.method === "initialized") {
166
+ applyPublishSteps("initialized", undefined, undefined);
167
+ }
168
+ if (message.method === "textDocument/didOpen") {
169
+ applyPublishSteps("didOpen", message.params.textDocument.uri, message.params.textDocument.version);
170
+ }
171
+ if (message.method === "textDocument/didChange" && Array.isArray(scenario.diagnostics)) {
172
+ send({
173
+ jsonrpc: "2.0",
174
+ method: "textDocument/publishDiagnostics",
175
+ params: {
176
+ uri: message.params.textDocument.uri,
177
+ version: message.params.textDocument.version,
178
+ diagnostics: scenario.diagnostics,
179
+ },
180
+ });
181
+ }
182
+ if (message.method === "textDocument/didChange") {
183
+ applyPublishSteps("didChange", message.params.textDocument.uri, message.params.textDocument.version);
184
+ }
185
+ if (message.method === "exit") process.exit(0);
186
+ }
187
+
188
+ function handleMessage(message) {
189
+ if (Object.hasOwn(message, "id") && (Object.hasOwn(message, "result") || Object.hasOwn(message, "error"))) {
190
+ handleClientResponse(message);
191
+ return;
192
+ }
193
+ if (Object.hasOwn(message, "id")) {
194
+ handleRequest(message);
195
+ return;
196
+ }
197
+ handleNotification(message);
198
+ }
199
+
200
+ process.stdin.on("data", (chunk) => {
201
+ buffer = Buffer.concat([buffer, chunk]);
202
+ for (;;) {
203
+ const headerEnd = buffer.indexOf("\r\n\r\n");
204
+ if (headerEnd === -1) return;
205
+ const header = buffer.subarray(0, headerEnd).toString("ascii");
206
+ const match = /content-length:\s*(\d+)/i.exec(header);
207
+ if (!match) process.exit(2);
208
+ const length = Number(match[1]);
209
+ const bodyStart = headerEnd + 4;
210
+ if (buffer.length < bodyStart + length) return;
211
+ const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf-8");
212
+ buffer = buffer.subarray(bodyStart + length);
213
+ handleMessage(JSON.parse(body));
214
+ }
215
+ });
@@ -127,6 +127,9 @@ export function formatApplyResult(result: ApplyResult): string {
127
127
  for (const file of result.filesModified) {
128
128
  lines.push(` - ${file}`);
129
129
  }
130
+ if (result.lateAbort) {
131
+ lines.push("Cancellation arrived after the filesystem commit began; the committed edit completed.");
132
+ }
130
133
  } else {
131
134
  lines.push("Failed to apply some changes:");
132
135
  for (const err of result.errors) {
@@ -0,0 +1,97 @@
1
+ import { PassThrough } from "node:stream";
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+
5
+ import { JsonRpcConnection } from "./json-rpc-connection.js";
6
+
7
+ type JsonMessage = Record<string, unknown>;
8
+
9
+ function messageId(message: JsonMessage): string | number | null | undefined {
10
+ const id = message["id"];
11
+ return typeof id === "string" || typeof id === "number" || id === null ? id : undefined;
12
+ }
13
+
14
+ function cancelTarget(message: JsonMessage): string | number | undefined {
15
+ const params = message["params"];
16
+ if (!params || typeof params !== "object" || Array.isArray(params)) return undefined;
17
+ const id = Reflect.get(params, "id");
18
+ return typeof id === "string" || typeof id === "number" ? id : undefined;
19
+ }
20
+
21
+ function encodeMessage(message: JsonMessage): string {
22
+ const body = JSON.stringify(message);
23
+ return `Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n\r\n${body}`;
24
+ }
25
+
26
+ function recordJsonRpcMessages(stream: PassThrough) {
27
+ const messages: JsonMessage[] = [];
28
+ const waiters: Array<() => void> = [];
29
+ let buffer = Buffer.alloc(0);
30
+ stream.on("data", (chunk: Buffer) => {
31
+ buffer = Buffer.concat([buffer, chunk]);
32
+ for (;;) {
33
+ const headerEnd = buffer.indexOf("\r\n\r\n");
34
+ if (headerEnd === -1) return;
35
+ const header = buffer.subarray(0, headerEnd).toString("ascii");
36
+ const match = /content-length:\s*(\d+)/i.exec(header);
37
+ if (!match?.[1]) throw new Error("missing content-length");
38
+ const bodyStart = headerEnd + 4;
39
+ const bodyEnd = bodyStart + Number.parseInt(match[1], 10);
40
+ if (buffer.length < bodyEnd) return;
41
+ const parsed: unknown = JSON.parse(buffer.subarray(bodyStart, bodyEnd).toString("utf8"));
42
+ buffer = buffer.subarray(bodyEnd);
43
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
44
+ messages.push(Object.fromEntries(Object.entries(parsed)));
45
+ }
46
+ for (const wake of waiters.splice(0)) wake();
47
+ }
48
+ });
49
+ return {
50
+ messages,
51
+ waitForCount(count: number): Promise<void> {
52
+ if (messages.length >= count) return Promise.resolve();
53
+ return new Promise((resolve, reject) => {
54
+ const timer = setTimeout(() => {
55
+ reject(new Error(`waited for ${count} JSON-RPC messages, observed ${messages.length}`));
56
+ }, 1_000);
57
+ const wake = (): void => {
58
+ if (messages.length < count) {
59
+ waiters.push(wake);
60
+ return;
61
+ }
62
+ clearTimeout(timer);
63
+ resolve();
64
+ };
65
+ waiters.push(wake);
66
+ });
67
+ },
68
+ };
69
+ }
70
+
71
+ describe("JsonRpcConnection cancellation", () => {
72
+ it("#given an active request #when its signal aborts #then cancel uses the same id and late responses are ignored", async () => {
73
+ const serverToClient = new PassThrough();
74
+ const clientToServer = new PassThrough();
75
+ const recorder = recordJsonRpcMessages(clientToServer);
76
+ const connection = new JsonRpcConnection(serverToClient, clientToServer);
77
+ connection.listen();
78
+ const controller = new AbortController();
79
+
80
+ const request = connection.sendRequest("textDocument/diagnostic", { textDocument: { uri: "file:///a.ts" } }, {
81
+ signal: controller.signal,
82
+ });
83
+ await recorder.waitForCount(1);
84
+ const requestId = messageId(recorder.messages[0] ?? {});
85
+ expect(typeof requestId === "string" || typeof requestId === "number").toBe(true);
86
+
87
+ controller.abort(new Error("caller cancelled"));
88
+ await recorder.waitForCount(2);
89
+ await expect(request).rejects.toThrow("caller cancelled");
90
+ expect(connection.pendingRequestCount()).toBe(0);
91
+ expect(recorder.messages[1]?.["method"]).toBe("$/cancelRequest");
92
+ expect(cancelTarget(recorder.messages[1] ?? {})).toBe(typeof requestId === "number" || typeof requestId === "string" ? requestId : undefined);
93
+
94
+ serverToClient.write(encodeMessage({ jsonrpc: "2.0", id: requestId ?? null, result: { items: [] } }));
95
+ expect(connection.pendingRequestCount()).toBe(0);
96
+ });
97
+ });
@@ -3,11 +3,16 @@ type JsonRpcId = number | string | null;
3
3
  interface PendingRequest {
4
4
  resolve(result: unknown): void;
5
5
  reject(error: Error): void;
6
+ cleanup(): void;
6
7
  }
7
8
 
8
9
  type NotificationHandler = (params: unknown) => void;
9
10
  type RequestHandler = (params: unknown) => Promise<unknown> | unknown;
10
11
 
12
+ export interface JsonRpcRequestOptions {
13
+ readonly signal?: AbortSignal;
14
+ }
15
+
11
16
  const HEADER_SEPARATOR = "\r\n\r\n";
12
17
  const PARSE_ERROR = -32700;
13
18
  const INVALID_REQUEST = -32600;
@@ -56,32 +61,81 @@ export class JsonRpcConnection {
56
61
  this.errorHandlers.push(handler);
57
62
  }
58
63
 
59
- async sendRequest<T>(method: string, params?: unknown): Promise<T> {
64
+ async sendRequest<T>(method: string, params?: unknown, options: JsonRpcRequestOptions = {}): Promise<T> {
60
65
  if (this.disposed) throw new Error("JSON-RPC connection is disposed");
61
66
 
62
67
  const id = this.nextRequestId;
63
68
  this.nextRequestId += 1;
69
+ const key = String(id);
64
70
  const message = params === undefined ? { jsonrpc: "2.0", id, method } : { jsonrpc: "2.0", id, method, params };
71
+ let requestWritten = false;
72
+ let cancelAfterWrite = false;
73
+ let settled = false;
74
+ const writeCancel = (): Promise<void> => this.writeMessage({ jsonrpc: "2.0", method: "$/cancelRequest", params: { id } });
65
75
 
66
76
  const responsePromise = new Promise<T>((resolve, reject) => {
67
- this.pendingRequests.set(String(id), {
77
+ const cleanup = (): void => {
78
+ options.signal?.removeEventListener("abort", onAbort);
79
+ };
80
+ const settleCancel = (): void => {
81
+ if (settled) return;
82
+ settled = true;
83
+ this.pendingRequests.delete(key);
84
+ cleanup();
85
+ const rejectCancelled = (): void => reject(abortError(options.signal));
86
+ if (!requestWritten) {
87
+ cancelAfterWrite = true;
88
+ rejectCancelled();
89
+ return;
90
+ }
91
+ void writeCancel().then(rejectCancelled, (error) => {
92
+ this.emitError(toError(error));
93
+ rejectCancelled();
94
+ });
95
+ };
96
+ const onAbort = (): void => settleCancel();
97
+ this.pendingRequests.set(key, {
68
98
  resolve(result) {
99
+ settled = true;
100
+ cleanup();
69
101
  resolve(result as T);
70
102
  },
71
- reject,
103
+ reject(error) {
104
+ settled = true;
105
+ cleanup();
106
+ reject(error);
107
+ },
108
+ cleanup,
72
109
  });
110
+ if (options.signal?.aborted) {
111
+ settleCancel();
112
+ return;
113
+ }
114
+ options.signal?.addEventListener("abort", onAbort, { once: true });
73
115
  });
74
116
 
117
+ if (settled) return responsePromise;
75
118
  try {
76
119
  await this.writeMessage(message);
120
+ requestWritten = true;
121
+ if (cancelAfterWrite) await writeCancel();
77
122
  } catch (error) {
78
- this.pendingRequests.delete(String(id));
123
+ if (settled) return responsePromise;
124
+ const pending = this.pendingRequests.get(key);
125
+ if (pending) {
126
+ pending.cleanup();
127
+ this.pendingRequests.delete(key);
128
+ }
79
129
  throw error;
80
130
  }
81
131
 
82
132
  return responsePromise;
83
133
  }
84
134
 
135
+ pendingRequestCount(): number {
136
+ return this.pendingRequests.size;
137
+ }
138
+
85
139
  async sendNotification(method: string, params?: unknown): Promise<void> {
86
140
  if (this.disposed) return;
87
141
  const message = params === undefined ? { jsonrpc: "2.0", method } : { jsonrpc: "2.0", method, params };
@@ -97,6 +151,7 @@ export class JsonRpcConnection {
97
151
  this.reader.off("error", this.handleStreamError);
98
152
  this.writer.off("error", this.handleStreamError);
99
153
  for (const pending of this.pendingRequests.values()) {
154
+ pending.cleanup();
100
155
  pending.reject(new Error("JSON-RPC connection disposed"));
101
156
  }
102
157
  this.pendingRequests.clear();
@@ -188,6 +243,7 @@ export class JsonRpcConnection {
188
243
  const pending = this.pendingRequests.get(String(id));
189
244
  if (!pending) return;
190
245
  this.pendingRequests.delete(String(id));
246
+ pending.cleanup();
191
247
 
192
248
  if ("error" in message) {
193
249
  pending.reject(jsonRpcErrorToError(message["error"]));
@@ -203,7 +259,11 @@ export class JsonRpcConnection {
203
259
  try {
204
260
  handler(params);
205
261
  } catch (error) {
206
- this.emitError(toError(error));
262
+ if (error instanceof Error) {
263
+ this.emitError(error);
264
+ return;
265
+ }
266
+ this.emitError(new Error(String(error)));
207
267
  }
208
268
  }
209
269
 
@@ -259,6 +319,14 @@ export class JsonRpcConnection {
259
319
  }
260
320
  }
261
321
 
322
+ function abortError(signal: AbortSignal | undefined): Error {
323
+ const reason = signal?.reason;
324
+ if (reason instanceof Error) return reason;
325
+ const error = new Error(typeof reason === "string" ? reason : "LSP request cancelled");
326
+ error.name = "AbortError";
327
+ return error;
328
+ }
329
+
262
330
  function parseContentLength(headers: string): number | null {
263
331
  for (const line of headers.split("\r\n")) {
264
332
  const separatorIndex = line.indexOf(":");
@@ -1,8 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join } from "node:path";
2
+ import { dirname } from "node:path";
4
3
 
5
- import { contextEnv } from "../request-context.js";
4
+ import { lspRequestContext } from "../request-context.js";
6
5
 
7
6
  export type InstallDecision = "declined" | "allowed";
8
7
 
@@ -14,9 +13,7 @@ export interface InstallDecisionRecord {
14
13
  type InstallDecisions = Record<string, InstallDecisionRecord>;
15
14
 
16
15
  export function getInstallDecisionsPath(): string {
17
- const override = contextEnv("LSP_TOOLS_MCP_INSTALL_DECISIONS");
18
- if (!override) return join(homedir(), ".codex", "lsp-install-decisions.json");
19
- return isAbsolute(override) ? override : join(homedir(), override);
16
+ return lspRequestContext().installDecisionsPath;
20
17
  }
21
18
 
22
19
  export function loadInstallDecisions(): InstallDecisions {