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,261 @@
1
+ import { writeFileSync } from "node:fs";
2
+
3
+ import { afterEach, describe, expect, it } from "bun:test";
4
+
5
+ import {
6
+ createWorkspaceEditTestHarness,
7
+ diagnostic,
8
+ readEvents,
9
+ waitForEventCount,
10
+ } from "./workspace-apply-edit-test-support.js";
11
+
12
+ const harness = createWorkspaceEditTestHarness();
13
+
14
+ afterEach(async () => {
15
+ await harness.cleanup();
16
+ });
17
+
18
+ describe("LspClient diagnostics freshness", () => {
19
+ it("#given concurrent diagnostics on a cold file and an exact didOpen publish #when pull is not advertised #then one didOpen opens the file and both requests receive the current diagnostics", async () => {
20
+ const context = await harness.makeClient(
21
+ {
22
+ publishDiagnostics: [
23
+ {
24
+ trigger: "didOpen",
25
+ version: 1,
26
+ diagnostics: [diagnostic("exact-current")],
27
+ },
28
+ ],
29
+ },
30
+ { diagnosticsFreshnessTimeoutMs: 50, versionlessPublishQuiescenceMs: 5 },
31
+ );
32
+
33
+ const [first, second] = await Promise.all([
34
+ context.client.diagnostics(context.source),
35
+ context.client.diagnostics(context.source),
36
+ ]);
37
+
38
+ expect(first.items).toEqual([diagnostic("exact-current")]);
39
+ expect(second.items).toEqual([diagnostic("exact-current")]);
40
+ expect(
41
+ readEvents(context.events).filter(
42
+ (event) => event.type === "clientNotification" && event.method === "textDocument/didOpen",
43
+ ),
44
+ ).toHaveLength(1);
45
+ });
46
+
47
+ it("#given a versionless publish that arrives after the current change #when no newer eligible publish arrives before quiescence #then diagnostics wait for that quiescence window and return the versionless payload", async () => {
48
+ const context = await harness.makeClient(
49
+ {
50
+ publishDiagnostics: [
51
+ {
52
+ trigger: "didChange",
53
+ diagnostics: [diagnostic("post-generation-versionless")],
54
+ },
55
+ ],
56
+ },
57
+ { diagnosticsFreshnessTimeoutMs: 80, versionlessPublishQuiescenceMs: 20 },
58
+ );
59
+ await context.client.openFile(context.source);
60
+ await waitForEventCount(
61
+ context.events,
62
+ (event) => event.type === "serverNotification" && event.method === "textDocument/publishDiagnostics",
63
+ 1,
64
+ );
65
+ writeFileSync(context.source, "const after = 1;\n", "utf-8");
66
+ await context.client.openFile(context.source);
67
+
68
+ const startedAt = Date.now();
69
+ const result = await context.client.diagnostics(context.source);
70
+ const elapsedMs = Date.now() - startedAt;
71
+
72
+ expect(result.items).toEqual([diagnostic("post-generation-versionless")]);
73
+ expect(elapsedMs).toBeGreaterThanOrEqual(15);
74
+ });
75
+
76
+ it("#given only a pre-generation versionless publish #when a newer local version asks for diagnostics #then the stale versionless publish is ignored and the request does not resolve clean", async () => {
77
+ const context = await harness.makeClient(
78
+ {
79
+ publishDiagnostics: [
80
+ {
81
+ trigger: "didOpen",
82
+ diagnostics: [diagnostic("pre-generation-versionless")],
83
+ },
84
+ ],
85
+ },
86
+ { diagnosticsFreshnessTimeoutMs: 300, versionlessPublishQuiescenceMs: 5 },
87
+ );
88
+ await context.client.openFile(context.source);
89
+ expect((await context.client.diagnostics(context.source)).items).toEqual([diagnostic("pre-generation-versionless")]);
90
+ writeFileSync(context.source, "const after = 1;\n", "utf-8");
91
+ await context.client.openFile(context.source);
92
+
93
+ const result = await context.client.diagnostics(context.source);
94
+
95
+ expect(result.items).toEqual([]);
96
+ expect(result.transientError?.kind).toBe("freshness_timeout");
97
+ });
98
+
99
+ it("#given stale and future publish versions #when diagnostics target the current version #then neither stale nor future diagnostics satisfy the request", async () => {
100
+ const stale = await harness.makeClient(
101
+ {
102
+ publishDiagnostics: [
103
+ {
104
+ trigger: "didChange",
105
+ version: 1,
106
+ diagnostics: [diagnostic("stale")],
107
+ },
108
+ ],
109
+ },
110
+ { diagnosticsFreshnessTimeoutMs: 30, versionlessPublishQuiescenceMs: 5 },
111
+ );
112
+ await stale.client.openFile(stale.source);
113
+ writeFileSync(stale.source, "const stale = 1;\n", "utf-8");
114
+ await stale.client.openFile(stale.source);
115
+
116
+ const staleResult = await stale.client.diagnostics(stale.source);
117
+
118
+ expect(staleResult.items).toEqual([]);
119
+ expect(staleResult.transientError?.kind).toBe("freshness_timeout");
120
+
121
+ await harness.cleanup();
122
+
123
+ const future = await harness.makeClient(
124
+ {
125
+ publishDiagnostics: [
126
+ {
127
+ trigger: "didChange",
128
+ version: 3,
129
+ diagnostics: [diagnostic("future")],
130
+ },
131
+ ],
132
+ },
133
+ { diagnosticsFreshnessTimeoutMs: 30, versionlessPublishQuiescenceMs: 5 },
134
+ );
135
+ await future.client.openFile(future.source);
136
+ writeFileSync(future.source, "const future = 1;\n", "utf-8");
137
+ await future.client.openFile(future.source);
138
+
139
+ const futureResult = await future.client.diagnostics(future.source);
140
+
141
+ expect(futureResult.items).toEqual([]);
142
+ expect(futureResult.transientError?.kind).toBe("freshness_timeout");
143
+ });
144
+
145
+ it("#given a pull response overtaken by a later local change #when the stale full report returns first #then diagnostics restart within the same request and resolve from the current version", async () => {
146
+ const context = await harness.makeClient(
147
+ {
148
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
149
+ diagnosticResponses: [
150
+ {
151
+ delayMs: 200,
152
+ report: { kind: "full", resultId: "v1", items: [diagnostic("pull-stale")] },
153
+ },
154
+ {
155
+ report: { kind: "full", resultId: "v2", items: [diagnostic("pull-fresh")] },
156
+ },
157
+ ],
158
+ },
159
+ { diagnosticsFreshnessTimeoutMs: 800, versionlessPublishQuiescenceMs: 5 },
160
+ );
161
+ await context.client.openFile(context.source);
162
+
163
+ const pending = context.client.diagnostics(context.source);
164
+ await waitForEventCount(
165
+ context.events,
166
+ (event) => event.type === "clientRequest" && event.method === "textDocument/diagnostic",
167
+ 1,
168
+ );
169
+ writeFileSync(context.source, "const pull_fresh = 1;\n", "utf-8");
170
+ await context.client.openFile(context.source);
171
+
172
+ const result = await pending;
173
+
174
+ expect(result.items).toEqual([diagnostic("pull-fresh")]);
175
+ expect(
176
+ readEvents(context.events).filter(
177
+ (event) => event.type === "clientRequest" && event.method === "textDocument/diagnostic",
178
+ ),
179
+ ).toHaveLength(2);
180
+ });
181
+
182
+ it("#given a full pull report followed by an unchanged report for the same version and resultId #when diagnostics repeat without a local change #then the cached full items are reused", async () => {
183
+ const context = await harness.makeClient(
184
+ {
185
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
186
+ diagnosticResponses: [
187
+ {
188
+ report: { kind: "full", resultId: "same-version", items: [diagnostic("cached-full")] },
189
+ },
190
+ {
191
+ report: { kind: "unchanged", resultId: "same-version" },
192
+ },
193
+ ],
194
+ },
195
+ { diagnosticsFreshnessTimeoutMs: 500, versionlessPublishQuiescenceMs: 5 },
196
+ );
197
+ await context.client.openFile(context.source);
198
+
199
+ const first = await context.client.diagnostics(context.source);
200
+ const second = await context.client.diagnostics(context.source);
201
+
202
+ expect(first.items).toEqual([diagnostic("cached-full")]);
203
+ expect(second.items).toEqual([diagnostic("cached-full")]);
204
+ });
205
+
206
+ it("#given a server that claims pull support but closes instead of answering #when diagnostics run #then the transport failure is not labeled clean", async () => {
207
+ const context = await harness.makeClient(
208
+ {
209
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
210
+ diagnosticResponses: [{ action: "close" }],
211
+ },
212
+ { diagnosticsFreshnessTimeoutMs: 500, versionlessPublishQuiescenceMs: 5 },
213
+ );
214
+ await context.client.openFile(context.source);
215
+
216
+ await expect(context.client.diagnostics(context.source)).rejects.toThrow(/exited|closed/i);
217
+ });
218
+
219
+ it("#given an advertised pull method that is explicitly unsupported and a matching push publish #when diagnostics run #then the client falls back to push diagnostics", async () => {
220
+ const context = await harness.makeClient(
221
+ {
222
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
223
+ publishDiagnostics: [
224
+ {
225
+ trigger: "didOpen",
226
+ version: 1,
227
+ diagnostics: [diagnostic("push-fallback")],
228
+ },
229
+ ],
230
+ diagnosticResponses: [{ error: { code: -32601, message: "Method not found" } }],
231
+ },
232
+ { diagnosticsFreshnessTimeoutMs: 500, versionlessPublishQuiescenceMs: 5 },
233
+ );
234
+
235
+ const result = await context.client.diagnostics(context.source);
236
+
237
+ expect(result.items).toEqual([diagnostic("push-fallback")]);
238
+ });
239
+
240
+ it("#given a diagnostic pull request times out #when the fake server keeps it pending #then the client sends cancel for the LSP request id", async () => {
241
+ const context = await harness.makeClient(
242
+ {
243
+ capabilities: { diagnosticProvider: { interFileDependencies: false, workspaceDiagnostics: false } },
244
+ diagnosticResponses: [{ action: "hang" }],
245
+ },
246
+ { requestTimeoutMs: 30, diagnosticsFreshnessTimeoutMs: 50, versionlessPublishQuiescenceMs: 5 },
247
+ );
248
+
249
+ const result = await context.client.diagnostics(context.source);
250
+ const [cancel] = await waitForEventCount(
251
+ context.events,
252
+ (event) => event.type === "clientNotification" && event.method === "$/cancelRequest",
253
+ 1,
254
+ );
255
+ const events = readEvents(context.events);
256
+ const request = events.find((event) => event.type === "clientRequest" && event.method === "textDocument/diagnostic");
257
+
258
+ expect(result.transientError?.kind).toBe("freshness_timeout");
259
+ expect(cancel?.params).toEqual({ id: request?.id });
260
+ });
261
+ });
@@ -0,0 +1,63 @@
1
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, describe, expect, it } from "bun:test";
5
+
6
+ import { createStandaloneMcpRequestContext, runWithRequestContext } from "../request-context.js";
7
+ import { findWorkspaceRoot, resolvePathInsideContext } from "./client-wrapper.js";
8
+ import { LspInvalidPathError } from "./errors.js";
9
+
10
+ const tempDirectories: string[] = [];
11
+
12
+ afterEach(() => {
13
+ for (const directory of tempDirectories.splice(0)) {
14
+ rmSync(directory, { recursive: true, force: true });
15
+ }
16
+ });
17
+
18
+ function tempRoot(prefix: string): string {
19
+ const root = mkdtempSync(join(tmpdir(), prefix));
20
+ tempDirectories.push(root);
21
+ return root;
22
+ }
23
+
24
+ describe("LSP client path confinement", () => {
25
+ it("#given a relative file inside context cwd #when resolving workspace #then marker search stays inside cwd", () => {
26
+ const root = tempRoot("lsp-client-wrapper-root-");
27
+ mkdirSync(join(root, ".git"), { recursive: true });
28
+ mkdirSync(join(root, "src"), { recursive: true });
29
+ writeFileSync(join(root, "src", "file.ts"), "export const value = 1;\n");
30
+
31
+ const workspace = runWithRequestContext(createStandaloneMcpRequestContext({ cwd: root }), () =>
32
+ findWorkspaceRoot("src/file.ts"),
33
+ );
34
+
35
+ expect(workspace).toBe(realpathSync(root));
36
+ });
37
+
38
+ it("#given an absolute file outside context cwd #when resolving #then rejects before workspace inference", () => {
39
+ const root = tempRoot("lsp-client-wrapper-cwd-");
40
+ const outside = tempRoot("lsp-client-wrapper-outside-");
41
+ mkdirSync(join(outside, ".git"), { recursive: true });
42
+ writeFileSync(join(outside, "file.ts"), "export const outside = true;\n");
43
+
44
+ expect(() =>
45
+ runWithRequestContext(createStandaloneMcpRequestContext({ cwd: root }), () =>
46
+ findWorkspaceRoot(join(outside, "file.ts")),
47
+ ),
48
+ ).toThrow(LspInvalidPathError);
49
+ });
50
+
51
+ it("#given a symlink inside cwd that points outside #when resolving #then rejects the escape", () => {
52
+ const root = tempRoot("lsp-client-wrapper-symlink-root-");
53
+ const outside = tempRoot("lsp-client-wrapper-symlink-outside-");
54
+ writeFileSync(join(outside, "file.ts"), "export const outside = true;\n");
55
+ symlinkSync(outside, join(root, "linked"));
56
+
57
+ expect(() =>
58
+ runWithRequestContext(createStandaloneMcpRequestContext({ cwd: root }), () =>
59
+ resolvePathInsideContext("linked/file.ts"),
60
+ ),
61
+ ).toThrow(LspInvalidPathError);
62
+ });
63
+ });
@@ -1,6 +1,11 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { contextCwd } from "../request-context.js";
3
+ import {
4
+ canonicalizeExistingOrNearestAncestor,
5
+ contextCwd,
6
+ isPathInside,
7
+ lspRequestContext,
8
+ } from "../request-context.js";
4
9
  import type { LspClient } from "./client.js";
5
10
  import { effectiveExtension } from "./effective-extension.js";
6
11
  import {
@@ -26,7 +31,7 @@ export function isDirectoryPath(filePath: string): boolean {
26
31
  }
27
32
 
28
33
  export function findWorkspaceRoot(filePath: string): string {
29
- const abs = resolve(contextCwd(), filePath);
34
+ const abs = resolvePathInsideContext(filePath);
30
35
  let dir = abs;
31
36
 
32
37
  if (!isDirectoryPath(dir)) {
@@ -47,10 +52,22 @@ export function findWorkspaceRoot(filePath: string): string {
47
52
  return dirname(abs);
48
53
  }
49
54
 
55
+ export function resolvePathInsideContext(filePath: string): string {
56
+ const cwd = contextCwd();
57
+ const abs = resolve(cwd, filePath);
58
+ const canonical = canonicalizeExistingOrNearestAncestor(abs);
59
+ if (!isPathInside(cwd, canonical)) {
60
+ throw new LspInvalidPathError(`LSP file path must be inside request cwd: ${filePath}`);
61
+ }
62
+ return canonical;
63
+ }
64
+
50
65
  export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
51
66
  if (result.status === "not_installed") {
52
67
  return formatNotInstalled(result);
53
68
  }
69
+ const context = lspRequestContext();
70
+ const firstProjectConfigPath = context.projectConfigPaths[0] ?? "<project lsp config>";
54
71
 
55
72
  return [
56
73
  `No LSP server configured for extension: ${result.extension}`,
@@ -59,7 +76,7 @@ export function formatServerLookupError(result: Exclude<ServerLookupResult, { st
59
76
  result.availableServers.length > 10 ? "..." : ""
60
77
  }`,
61
78
  "",
62
- "Configure a custom server in '.codex/lsp-client.json':",
79
+ `Configure a custom server in '${firstProjectConfigPath}' or '${context.userConfigPath}':`,
63
80
  " {",
64
81
  ' "lsp": {',
65
82
  ' "my-server": {',
@@ -75,6 +92,7 @@ function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_i
75
92
  const { server, installHint } = result;
76
93
  const extensions = server.extensions.join(", ");
77
94
  const decision = loadInstallDecision(server.id)?.decision;
95
+ const context = lspRequestContext();
78
96
 
79
97
  if (decision === "declined") {
80
98
  return `LSP server '${server.id}' (${extensions}) is NOT INSTALLED; user previously declined installation — proceed without LSP.`;
@@ -95,6 +113,17 @@ function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_i
95
113
  ].join("\n");
96
114
  }
97
115
 
116
+ if (!context.capabilities.installDecisionTool) {
117
+ return [
118
+ ...header,
119
+ "To install, run:",
120
+ ` ${installHint}`,
121
+ "",
122
+ "ACTION REQUIRED — ASK THE USER whether to install this LSP server.",
123
+ "Install-decision recording is unavailable in this harness; proceed without LSP if the user declines.",
124
+ ].join("\n");
125
+ }
126
+
98
127
  return [
99
128
  ...header,
100
129
  "To install, run:",
@@ -104,6 +133,7 @@ function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_i
104
133
  "- If the user agrees: run the install command above, then retry this tool.",
105
134
  "- If the user declines, OR has NOT explicitly asked for LSP installation:",
106
135
  ` call lsp_install_decision { server_id: "${server.id}", decision: "declined" },`,
136
+ ` which writes to ${context.installDecisionsPath},`,
107
137
  " then ignore this message and proceed WITHOUT LSP.",
108
138
  ].join("\n");
109
139
  }
@@ -128,7 +158,7 @@ export async function withLspClient<T>(
128
158
  toolName: string,
129
159
  options: WithLspClientOptions = {},
130
160
  ): Promise<T> {
131
- const absPath = resolve(contextCwd(), filePath);
161
+ const absPath = resolvePathInsideContext(filePath);
132
162
 
133
163
  if (isDirectoryPath(absPath)) {
134
164
  throw new LspInvalidPathError(
@@ -140,7 +170,7 @@ export async function withLspClient<T>(
140
170
  const ext = effectiveExtension(absPath);
141
171
  const result = findServerForExtension(ext);
142
172
  if (result.status !== "found") {
143
- throw new LspServerLookupError(formatServerLookupError(result));
173
+ throw new LspServerLookupError(formatServerLookupError(result), result);
144
174
  }
145
175
 
146
176
  const server = result.server;