oh-my-opencode 4.18.0 → 4.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. package/.agents/skills/codex-qa/scripts/lsp-e2e.sh +3654 -0
  2. package/.agents/skills/opencode-qa/scripts/lsp-e2e.sh +3071 -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/dist/cli/index.js +457 -154
  8. package/dist/cli-node/index.js +457 -154
  9. package/dist/index.js +415 -388
  10. package/package.json +16 -16
  11. package/packages/lsp-core/package.json +4 -0
  12. package/packages/lsp-core/src/index.ts +1 -0
  13. package/packages/lsp-core/src/lsp/cleanup-errors.test.ts +18 -0
  14. package/packages/lsp-core/src/lsp/cleanup-errors.ts +12 -3
  15. package/packages/lsp-core/src/lsp/client-diagnostics-freshness.integration.test.ts +261 -0
  16. package/packages/lsp-core/src/lsp/client-wrapper.test.ts +63 -0
  17. package/packages/lsp-core/src/lsp/client-wrapper.ts +35 -5
  18. package/packages/lsp-core/src/lsp/client.ts +262 -80
  19. package/packages/lsp-core/src/lsp/config-loader.ts +5 -17
  20. package/packages/lsp-core/src/lsp/connection.ts +12 -6
  21. package/packages/lsp-core/src/lsp/directory-diagnostics.test.ts +104 -0
  22. package/packages/lsp-core/src/lsp/directory-diagnostics.ts +60 -27
  23. package/packages/lsp-core/src/lsp/errors.ts +11 -0
  24. package/packages/lsp-core/src/lsp/fixtures/diagnostics-freshness-contract-probe.ts +283 -0
  25. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-contract-probe.ts +196 -0
  26. package/packages/lsp-core/src/lsp/fixtures/workspace-edit-server.mjs +215 -0
  27. package/packages/lsp-core/src/lsp/formatters.ts +3 -0
  28. package/packages/lsp-core/src/lsp/json-rpc-connection-cancellation.test.ts +97 -0
  29. package/packages/lsp-core/src/lsp/json-rpc-connection.ts +73 -5
  30. package/packages/lsp-core/src/lsp/server-install-state.ts +3 -6
  31. package/packages/lsp-core/src/lsp/transport-protocol.ts +52 -0
  32. package/packages/lsp-core/src/lsp/transport.ts +96 -70
  33. package/packages/lsp-core/src/lsp/workspace-apply-edit-failure.ts +19 -0
  34. package/packages/lsp-core/src/lsp/workspace-apply-edit-lease.integration.test.ts +214 -0
  35. package/packages/lsp-core/src/lsp/workspace-apply-edit-sync.integration.test.ts +113 -0
  36. package/packages/lsp-core/src/lsp/workspace-apply-edit-test-support.ts +163 -0
  37. package/packages/lsp-core/src/lsp/workspace-apply-edit.integration.test.ts +163 -0
  38. package/packages/lsp-core/src/lsp/workspace-document-state.test.ts +67 -0
  39. package/packages/lsp-core/src/lsp/workspace-document-state.ts +368 -0
  40. package/packages/lsp-core/src/lsp/workspace-edit-adversarial.test.ts +113 -0
  41. package/packages/lsp-core/src/lsp/workspace-edit-commit.test.ts +140 -0
  42. package/packages/lsp-core/src/lsp/workspace-edit-commit.ts +220 -0
  43. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.test.ts +56 -0
  44. package/packages/lsp-core/src/lsp/workspace-edit-contract-evidence.ts +30 -0
  45. package/packages/lsp-core/src/lsp/workspace-edit-fingerprint.ts +44 -0
  46. package/packages/lsp-core/src/lsp/workspace-edit-options.test.ts +147 -0
  47. package/packages/lsp-core/src/lsp/workspace-edit-parse-helpers.ts +59 -0
  48. package/packages/lsp-core/src/lsp/workspace-edit-parser.ts +130 -0
  49. package/packages/lsp-core/src/lsp/workspace-edit-path.ts +98 -0
  50. package/packages/lsp-core/src/lsp/workspace-edit-plan-types.ts +60 -0
  51. package/packages/lsp-core/src/lsp/workspace-edit-plan.ts +73 -0
  52. package/packages/lsp-core/src/lsp/workspace-edit-prevalidation.test.ts +174 -0
  53. package/packages/lsp-core/src/lsp/workspace-edit-resource-parser.ts +89 -0
  54. package/packages/lsp-core/src/lsp/workspace-edit-simulation.ts +183 -0
  55. package/packages/lsp-core/src/lsp/workspace-edit-snapshot.ts +53 -0
  56. package/packages/lsp-core/src/lsp/workspace-edit-text.ts +125 -0
  57. package/packages/lsp-core/src/lsp/workspace-edit-types.ts +121 -0
  58. package/packages/lsp-core/src/lsp/workspace-edit.characterization.test.ts +95 -0
  59. package/packages/lsp-core/src/lsp/workspace-edit.ts +49 -200
  60. package/packages/lsp-core/src/lsp/workspace-mutation-controller.ts +182 -0
  61. package/packages/lsp-core/src/mcp.ts +18 -7
  62. package/packages/lsp-core/src/missing-dependency-result.test.ts +105 -0
  63. package/packages/lsp-core/src/missing-dependency-result.ts +57 -0
  64. package/packages/lsp-core/src/post-edit/index.ts +1 -0
  65. package/packages/lsp-core/src/post-edit/orchestration.test.ts +157 -0
  66. package/packages/lsp-core/src/post-edit/orchestration.ts +178 -0
  67. package/packages/lsp-core/src/request-context.test.ts +171 -0
  68. package/packages/lsp-core/src/request-context.ts +222 -9
  69. package/packages/lsp-core/src/tool-surface.test.ts +4 -1
  70. package/packages/lsp-core/src/tools/diagnostics.ts +32 -13
  71. package/packages/lsp-core/src/tools/navigation.ts +12 -12
  72. package/packages/lsp-core/src/tools/rename.ts +10 -15
  73. package/packages/lsp-core/src/tools/symbols.ts +11 -11
  74. package/packages/lsp-core/src/tools/types.ts +2 -1
  75. package/packages/lsp-daemon/dist/cli.js +3114 -747
  76. package/packages/lsp-daemon/dist/client.d.ts +105 -0
  77. package/packages/lsp-daemon/dist/client.js +5851 -0
  78. package/packages/lsp-daemon/dist/daemon-client.d.ts +11 -6
  79. package/packages/lsp-daemon/dist/daemon-client.js +113 -30
  80. package/packages/lsp-daemon/dist/daemon-server.d.ts +1 -0
  81. package/packages/lsp-daemon/dist/daemon-server.js +40 -15
  82. package/packages/lsp-daemon/dist/ensure-daemon.d.ts +8 -7
  83. package/packages/lsp-daemon/dist/ensure-daemon.js +67 -44
  84. package/packages/lsp-daemon/dist/index.d.ts +2 -2
  85. package/packages/lsp-daemon/dist/index.js +2862 -754
  86. package/packages/lsp-daemon/dist/ipc-protocol.d.ts +46 -0
  87. package/packages/lsp-daemon/dist/ipc-protocol.js +187 -0
  88. package/packages/lsp-daemon/dist/lock.js +14 -4
  89. package/packages/lsp-daemon/dist/ownership.d.ts +49 -0
  90. package/packages/lsp-daemon/dist/ownership.js +168 -0
  91. package/packages/lsp-daemon/dist/paths.d.ts +33 -9
  92. package/packages/lsp-daemon/dist/paths.js +72 -33
  93. package/packages/lsp-daemon/dist/proxy.d.ts +3 -0
  94. package/packages/lsp-daemon/dist/proxy.js +54 -3
  95. package/packages/lsp-daemon/dist/request-routing.d.ts +7 -2
  96. package/packages/lsp-daemon/dist/request-routing.js +71 -22
  97. package/packages/lsp-daemon/dist/run-daemon.js +9 -2
  98. package/packages/lsp-daemon/dist/runtime-contract.d.ts +21 -0
  99. package/packages/lsp-daemon/dist/runtime-contract.js +58 -0
  100. package/packages/lsp-daemon/dist/socket-jsonrpc.js +6 -1
  101. package/packages/lsp-daemon/package.json +12 -3
  102. package/packages/lsp-tools-mcp/dist/cli.js +2115 -442
  103. package/packages/lsp-tools-mcp/dist/lsp/manager.js +1741 -148
  104. package/packages/lsp-tools-mcp/dist/mcp.js +2127 -454
  105. package/packages/lsp-tools-mcp/dist/request-context.js +176 -6
  106. package/packages/lsp-tools-mcp/dist/tools.js +2118 -446
  107. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  108. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  109. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  110. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  111. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  112. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  113. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  114. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  115. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  116. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  117. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +55 -0
  118. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +2959 -944
  119. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook-cli.js +0 -4
  120. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.d.ts +5 -2
  121. package/packages/omo-codex/plugin/components/lsp/dist/codex-hook.js +41 -62
  122. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.d.ts +1 -1
  123. package/packages/omo-codex/plugin/components/lsp/dist/daemon-cli-path.js +24 -15
  124. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.d.ts +3 -7
  125. package/packages/omo-codex/plugin/components/lsp/dist/lsp-session-state.js +23 -49
  126. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  127. package/packages/omo-codex/plugin/components/lsp/package.json +3 -2
  128. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.mjs +31 -1
  129. package/packages/omo-codex/plugin/components/lsp/scripts/build-lsp-daemon.test.mjs +76 -0
  130. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.mjs +201 -0
  131. package/packages/omo-codex/plugin/components/lsp/scripts/build-runtime.test.mjs +55 -0
  132. package/packages/omo-codex/plugin/components/lsp/src/codex-hook-cli.ts +0 -4
  133. package/packages/omo-codex/plugin/components/lsp/src/codex-hook.ts +49 -71
  134. package/packages/omo-codex/plugin/components/lsp/src/daemon-cli-path.ts +26 -15
  135. package/packages/omo-codex/plugin/components/lsp/src/lsp-session-state.ts +26 -64
  136. package/packages/omo-codex/plugin/components/lsp/test/codex-hook-unavailable.test.ts +16 -17
  137. package/packages/omo-codex/plugin/components/lsp/test/codex-hook.test.ts +30 -4
  138. package/packages/omo-codex/plugin/components/lsp/test/package-smoke.test.ts +19 -5
  139. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.5.md +1 -1
  140. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +1 -1
  141. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  142. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  143. package/packages/omo-codex/plugin/components/start-work-continuation/directive.md +1 -1
  144. package/packages/omo-codex/plugin/components/start-work-continuation/hooks/hooks.json +2 -2
  145. package/packages/omo-codex/plugin/components/start-work-continuation/package.json +1 -1
  146. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  147. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  148. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  149. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  150. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  151. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  152. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  153. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  154. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/SKILL.md +1 -1
  155. package/packages/omo-codex/plugin/components/ulw-loop/skills/ulw-loop/references/full-workflow.md +6 -5
  156. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  157. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  158. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  159. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  160. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  161. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  162. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  163. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  164. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  165. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  166. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  167. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  168. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  169. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  170. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  171. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  172. package/packages/omo-codex/plugin/hooks/stop-checking-start-work-continuation.json +1 -1
  173. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  174. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-start-work-continuation.json +1 -1
  175. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  176. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  177. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  178. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  179. package/packages/omo-codex/plugin/package-lock.json +26 -14
  180. package/packages/omo-codex/plugin/package.json +1 -1
  181. package/packages/omo-codex/plugin/scripts/build-bundled-mcp-runtimes.mjs +2 -3
  182. package/packages/omo-codex/plugin/scripts/build-components.mjs +13 -1
  183. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +1 -1
  184. package/packages/omo-codex/plugin/skills/start-work/SKILL.md +1 -1
  185. package/packages/omo-codex/plugin/skills/ulw-loop/SKILL.md +1 -1
  186. package/packages/omo-codex/plugin/skills/ulw-loop/references/full-workflow.md +6 -5
  187. package/packages/omo-codex/plugin/test/aggregate-build.test.mjs +8 -0
  188. package/packages/omo-codex/plugin/test/component-bundled-cli.test.mjs +128 -15
  189. package/packages/omo-codex/plugin/test/install-time-build-runtime.test.mjs +10 -0
  190. package/packages/omo-codex/plugin/test/lsp-prebuild-layouts.test.mjs +2 -0
  191. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +1 -1
  192. package/packages/omo-codex/scripts/install-dist/install-local.mjs +328 -63
@@ -1,11 +1,8 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { resolve } from "node:path";
3
2
  import { pathToFileURL } from "node:url";
4
3
 
5
- import { contextCwd } from "../request-context.js";
6
4
  import { LspClientConnection } from "./connection.js";
7
- import { effectiveExtension } from "./effective-extension.js";
8
- import { getLanguageId } from "./language-mappings.js";
5
+ import type { LspClientTimeoutOptions } from "./transport.js";
9
6
  import type {
10
7
  Diagnostic,
11
8
  DocumentSymbol,
@@ -14,105 +11,131 @@ import type {
14
11
  PrepareRenameDefaultBehavior,
15
12
  PrepareRenameResult,
16
13
  Range,
14
+ ResolvedServer,
17
15
  SymbolInfo,
18
16
  WorkspaceEdit,
19
17
  } from "./types.js";
18
+ import { LspRequestTimeoutError } from "./errors.js";
19
+ import { WorkspaceDocumentState } from "./workspace-document-state.js";
20
+ import type { LspRenameResult, WorkspaceEditCommitIo } from "./workspace-edit-types.js";
21
+ import { WorkspaceMutationController } from "./workspace-mutation-controller.js";
20
22
 
21
- const POST_OPEN_DELAY_MS = 1000;
22
- const POST_DIAGNOSTICS_WAIT_MS = 500;
23
+ const DIAGNOSTICS_FRESHNESS_TIMEOUT_MS = 3_000;
24
+ const VERSIONLESS_PUBLISH_QUIESCENCE_MS = 250;
25
+
26
+ export interface LspDiagnosticsResult {
27
+ readonly items: Diagnostic[];
28
+ readonly transientError?: {
29
+ readonly kind: "freshness_timeout";
30
+ readonly message: string;
31
+ };
32
+ }
33
+
34
+ export interface LspClientOptions extends LspClientTimeoutOptions {
35
+ readonly diagnosticsFreshnessTimeoutMs?: number;
36
+ readonly versionlessPublishQuiescenceMs?: number;
37
+ }
23
38
 
24
39
  export class LspClient extends LspClientConnection {
25
- private readonly openedFiles = new Set<string>();
26
- private readonly documentVersions = new Map<string, number>();
27
- private readonly lastSyncedText = new Map<string, string>();
28
40
  private readonly diagnosticPullErrors: Error[] = [];
41
+ private readonly documents: WorkspaceDocumentState;
42
+ private readonly workspaceMutations: WorkspaceMutationController;
43
+ private readonly diagnosticsFreshnessTimeoutMs: number;
44
+
45
+ constructor(root: string, server: ResolvedServer, options: LspClientOptions = {}) {
46
+ super(root, server, options);
47
+ this.diagnosticsFreshnessTimeoutMs =
48
+ options.diagnosticsFreshnessTimeoutMs ?? DIAGNOSTICS_FRESHNESS_TIMEOUT_MS;
49
+ this.documents = new WorkspaceDocumentState(
50
+ (method, params) => this.sendNotification(method, params),
51
+ (uri) => this.diagnosticsStore.delete(uri),
52
+ {
53
+ versionlessPublishQuiescenceMs:
54
+ options.versionlessPublishQuiescenceMs ?? VERSIONLESS_PUBLISH_QUIESCENCE_MS,
55
+ },
56
+ );
57
+ this.workspaceMutations = new WorkspaceMutationController(root, this.documents);
58
+ this.setWorkspaceApplyEditHandler((params) => this.workspaceMutations.handleApplyEdit(params));
59
+ }
29
60
 
30
61
  getDiagnosticPullErrors(): readonly Error[] {
31
62
  return this.diagnosticPullErrors;
32
63
  }
33
64
 
34
65
  async openFile(filePath: string): Promise<void> {
35
- const absPath = resolve(contextCwd(), filePath);
36
- const uri = pathToFileURL(absPath).href;
37
- const text = readFileSync(absPath, "utf-8");
38
-
39
- if (!this.openedFiles.has(absPath)) {
40
- const ext = effectiveExtension(absPath);
41
- const languageId = getLanguageId(ext);
42
- const version = 1;
43
-
44
- await this.sendNotification("textDocument/didOpen", {
45
- textDocument: {
46
- uri,
47
- languageId,
48
- version,
49
- text,
50
- },
51
- });
52
-
53
- this.openedFiles.add(absPath);
54
- this.documentVersions.set(uri, version);
55
- this.lastSyncedText.set(uri, text);
56
- await new Promise((r) => setTimeout(r, POST_OPEN_DELAY_MS));
57
- return;
58
- }
66
+ const absPath = this.resolveWorkspacePath(filePath);
67
+ await this.documents.openFile(absPath);
68
+ }
59
69
 
60
- const prevText = this.lastSyncedText.get(uri);
61
- if (prevText === text) {
62
- return;
63
- }
70
+ getOpenDocumentVersion(filePath: string): number | undefined {
71
+ return this.documents.getVersion(this.resolveWorkspacePath(filePath));
72
+ }
64
73
 
65
- const nextVersion = (this.documentVersions.get(uri) ?? 1) + 1;
66
- this.documentVersions.set(uri, nextVersion);
67
- this.lastSyncedText.set(uri, text);
74
+ override getStoredDiagnostics(uri: string): Diagnostic[] {
75
+ return [...this.documents.getStoredDiagnostics(uri)];
76
+ }
68
77
 
69
- await this.sendNotification("textDocument/didChange", {
70
- textDocument: { uri, version: nextVersion },
71
- contentChanges: [{ text }],
72
- });
78
+ setWorkspaceEditIo(io: Partial<WorkspaceEditCommitIo>): void {
79
+ this.workspaceMutations.setIo(io);
80
+ }
73
81
 
74
- await this.sendNotification("textDocument/didSave", {
75
- textDocument: { uri },
76
- text,
77
- });
82
+ protected override handlePublishDiagnostics(params: {
83
+ readonly uri: string;
84
+ readonly diagnostics: readonly Diagnostic[];
85
+ readonly version?: number;
86
+ }): void {
87
+ super.handlePublishDiagnostics(params);
88
+ this.documents.recordPublishedDiagnostics(params);
78
89
  }
79
90
 
80
91
  async definition(
81
92
  filePath: string,
82
93
  line: number,
83
94
  character: number,
95
+ signal?: AbortSignal,
84
96
  ): Promise<Location | LocationLink | Array<Location | LocationLink> | null> {
85
- const absPath = resolve(contextCwd(), filePath);
97
+ const absPath = this.resolveWorkspacePath(filePath);
86
98
  await this.openFile(absPath);
99
+ const options = signal === undefined ? {} : { signal };
87
100
  return this.sendRequest<Location | LocationLink | Array<Location | LocationLink> | null>(
88
101
  "textDocument/definition",
89
102
  {
90
103
  textDocument: { uri: pathToFileURL(absPath).href },
91
104
  position: { line: line - 1, character },
92
105
  },
106
+ options,
93
107
  );
94
108
  }
95
109
 
96
- async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise<Location[]> {
97
- const absPath = resolve(contextCwd(), filePath);
110
+ async references(
111
+ filePath: string,
112
+ line: number,
113
+ character: number,
114
+ includeDeclaration = true,
115
+ signal?: AbortSignal,
116
+ ): Promise<Location[]> {
117
+ const absPath = this.resolveWorkspacePath(filePath);
98
118
  await this.openFile(absPath);
119
+ const options = signal === undefined ? {} : { signal };
99
120
  return this.sendRequest<Location[]>("textDocument/references", {
100
121
  textDocument: { uri: pathToFileURL(absPath).href },
101
122
  position: { line: line - 1, character },
102
123
  context: { includeDeclaration },
103
- });
124
+ }, options);
104
125
  }
105
126
 
106
- async documentSymbols(filePath: string): Promise<Array<DocumentSymbol | SymbolInfo>> {
107
- const absPath = resolve(contextCwd(), filePath);
127
+ async documentSymbols(filePath: string, signal?: AbortSignal): Promise<Array<DocumentSymbol | SymbolInfo>> {
128
+ const absPath = this.resolveWorkspacePath(filePath);
108
129
  await this.openFile(absPath);
130
+ const options = signal === undefined ? {} : { signal };
109
131
  return this.sendRequest<Array<DocumentSymbol | SymbolInfo>>("textDocument/documentSymbol", {
110
132
  textDocument: { uri: pathToFileURL(absPath).href },
111
- });
133
+ }, options);
112
134
  }
113
135
 
114
- async workspaceSymbols(query: string): Promise<SymbolInfo[]> {
115
- return this.sendRequest<SymbolInfo[]>("workspace/symbol", { query });
136
+ async workspaceSymbols(query: string, signal?: AbortSignal): Promise<SymbolInfo[]> {
137
+ const options = signal === undefined ? {} : { signal };
138
+ return this.sendRequest<SymbolInfo[]>("workspace/symbol", { query }, options);
116
139
  }
117
140
 
118
141
  private isUnsupportedDiagnosticPullError(error: unknown): boolean {
@@ -122,51 +145,210 @@ export class LspClient extends LspClientConnection {
122
145
  return /unsupported|not supported|method not found|unknown request/i.test(error.message);
123
146
  }
124
147
 
125
- async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
126
- const absPath = resolve(contextCwd(), filePath);
148
+ private freshnessTimeout(absPath: string): LspDiagnosticsResult {
149
+ return {
150
+ items: [],
151
+ transientError: {
152
+ kind: "freshness_timeout",
153
+ message: `Timed out waiting for fresh diagnostics for ${absPath} within ${this.diagnosticsFreshnessTimeoutMs}ms.`,
154
+ },
155
+ };
156
+ }
157
+
158
+ private parseDiagnosticPullReport(value: { items?: Diagnostic[]; kind?: string; resultId?: string }): {
159
+ readonly type: "full";
160
+ readonly diagnostics: readonly Diagnostic[];
161
+ readonly resultId?: string;
162
+ } | {
163
+ readonly type: "unchanged";
164
+ readonly resultId?: string;
165
+ } {
166
+ if (value.kind === "unchanged") {
167
+ return {
168
+ type: "unchanged",
169
+ ...(value.resultId === undefined ? {} : { resultId: value.resultId }),
170
+ };
171
+ }
172
+ return {
173
+ type: "full",
174
+ diagnostics: value.items ?? [],
175
+ ...(value.resultId === undefined ? {} : { resultId: value.resultId }),
176
+ };
177
+ }
178
+
179
+ async diagnostics(filePath: string, signal?: AbortSignal): Promise<LspDiagnosticsResult> {
180
+ signal?.throwIfAborted();
181
+ const absPath = this.resolveWorkspacePath(filePath);
127
182
  const uri = pathToFileURL(absPath).href;
128
183
  await this.openFile(absPath);
129
- await new Promise((r) => setTimeout(r, POST_DIAGNOSTICS_WAIT_MS));
184
+ const deadlineAt = Date.now() + this.diagnosticsFreshnessTimeoutMs;
130
185
 
131
- try {
132
- const result = await this.sendRequest<{ items?: Diagnostic[] }>("textDocument/diagnostic", {
133
- textDocument: { uri },
134
- });
135
- if (result.items) {
136
- return { items: result.items };
137
- }
138
- } catch (error) {
139
- if (!this.isUnsupportedDiagnosticPullError(error)) {
140
- this.diagnosticPullErrors.push(error instanceof Error ? error : new Error(String(error)));
186
+ for (;;) {
187
+ signal?.throwIfAborted();
188
+ const snapshot = this.documents.captureDiagnosticSnapshot(absPath);
189
+ if (!snapshot) return this.freshnessTimeout(absPath);
190
+ const push = this.documents.resolvePushDiagnostics(snapshot);
191
+ if (push.status === "ready") return { items: [...push.diagnostics] };
192
+
193
+ let pushFallbackOnly = !this.isDiagnosticPullSupported();
194
+ if (!pushFallbackOnly) {
195
+ const cached = this.documents.getPullCache(snapshot);
196
+ try {
197
+ const remainingMs = deadlineAt - Date.now();
198
+ if (remainingMs <= 0) return this.freshnessTimeout(absPath);
199
+ const result = await this.sendRequest<{ items?: Diagnostic[]; kind?: string; resultId?: string }>(
200
+ "textDocument/diagnostic",
201
+ {
202
+ textDocument: { uri },
203
+ ...(cached?.resultId === undefined ? {} : { previousResultId: cached.resultId }),
204
+ },
205
+ { timeoutMs: remainingMs, ...(signal === undefined ? {} : { signal }) },
206
+ );
207
+ if (!this.documents.isCurrentSnapshot(snapshot)) continue;
208
+ const report = this.parseDiagnosticPullReport(result);
209
+ if (report.type === "full") {
210
+ this.documents.recordPullDiagnostics(snapshot, {
211
+ kind: "full",
212
+ diagnostics: report.diagnostics,
213
+ ...(report.resultId === undefined ? {} : { resultId: report.resultId }),
214
+ });
215
+ return { items: [...report.diagnostics] };
216
+ }
217
+ if (
218
+ cached !== null &&
219
+ cached.documentVersion === snapshot.version &&
220
+ cached.resultId === report.resultId
221
+ ) {
222
+ return { items: [...cached.diagnostics] };
223
+ }
224
+ } catch (error) {
225
+ if (this.isUnsupportedDiagnosticPullError(error)) {
226
+ this.setDiagnosticPullSupported(false);
227
+ pushFallbackOnly = true;
228
+ } else if (error instanceof LspRequestTimeoutError) {
229
+ pushFallbackOnly = true;
230
+ } else {
231
+ this.diagnosticPullErrors.push(error instanceof Error ? error : new Error(String(error)));
232
+ throw error;
233
+ }
234
+ }
141
235
  }
142
- }
143
236
 
144
- return { items: this.getStoredDiagnostics(uri) };
237
+ if (!pushFallbackOnly) continue;
238
+
239
+ const remainingMs = deadlineAt - Date.now();
240
+ if (remainingMs <= 0) return this.freshnessTimeout(absPath);
241
+ const waitMs = push.status === "wait" ? Math.min(push.waitMs, remainingMs) : remainingMs;
242
+ await waitForDiagnosticsActivity(this.documents.waitForDiagnosticsActivity(snapshot, waitMs), signal);
243
+ }
145
244
  }
146
245
 
147
246
  async prepareRename(
148
247
  filePath: string,
149
248
  line: number,
150
249
  character: number,
250
+ signal?: AbortSignal,
151
251
  ): Promise<PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null> {
152
- const absPath = resolve(contextCwd(), filePath);
252
+ const absPath = this.resolveWorkspacePath(filePath);
153
253
  await this.openFile(absPath);
254
+ const options = signal === undefined ? {} : { signal };
154
255
  return this.sendRequest<PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null>(
155
256
  "textDocument/prepareRename",
156
257
  {
157
258
  textDocument: { uri: pathToFileURL(absPath).href },
158
259
  position: { line: line - 1, character },
159
260
  },
261
+ options,
160
262
  );
161
263
  }
162
264
 
163
- async rename(filePath: string, line: number, character: number, newName: string): Promise<WorkspaceEdit | null> {
164
- const absPath = resolve(contextCwd(), filePath);
265
+ async rename(
266
+ filePath: string,
267
+ line: number,
268
+ character: number,
269
+ newName: string,
270
+ signal?: AbortSignal,
271
+ ): Promise<LspRenameResult> {
272
+ const absPath = this.resolveWorkspacePath(filePath);
165
273
  await this.openFile(absPath);
166
- return this.sendRequest<WorkspaceEdit | null>("textDocument/rename", {
167
- textDocument: { uri: pathToFileURL(absPath).href },
168
- position: { line: line - 1, character },
169
- newName,
170
- });
274
+ const acquired = this.workspaceMutations.acquire(signal);
275
+ if (!acquired.success) return { edit: null, apply: acquired.result };
276
+ const preCommitSignal = createPreCommitAbortSignal(signal, () =>
277
+ this.workspaceMutations.isBeforeCommit(acquired.lease),
278
+ );
279
+ try {
280
+ const renameParams = {
281
+ textDocument: { uri: pathToFileURL(absPath).href },
282
+ position: { line: line - 1, character },
283
+ newName,
284
+ };
285
+ const edit =
286
+ preCommitSignal === undefined
287
+ ? await this.sendRequest<WorkspaceEdit | null>("textDocument/rename", renameParams)
288
+ : await this.sendRequest<WorkspaceEdit | null>("textDocument/rename", renameParams, {
289
+ signal: preCommitSignal.signal,
290
+ });
291
+ return await this.workspaceMutations.reconcileRename(acquired.lease, edit);
292
+ } finally {
293
+ preCommitSignal?.dispose();
294
+ this.workspaceMutations.release(acquired.lease);
295
+ }
171
296
  }
297
+
298
+ private resolveWorkspacePath(filePath: string): string {
299
+ return resolve(this.root, filePath);
300
+ }
301
+ }
302
+
303
+ function waitForDiagnosticsActivity(wait: Promise<void>, signal: AbortSignal | undefined): Promise<void> {
304
+ if (!signal) return wait;
305
+ if (signal.aborted) return Promise.reject(abortError(signal));
306
+ return new Promise((resolve, reject) => {
307
+ const onAbort = (): void => {
308
+ signal.removeEventListener("abort", onAbort);
309
+ reject(abortError(signal));
310
+ };
311
+ signal.addEventListener("abort", onAbort, { once: true });
312
+ wait.then(
313
+ () => {
314
+ signal.removeEventListener("abort", onAbort);
315
+ resolve();
316
+ },
317
+ (error) => {
318
+ signal.removeEventListener("abort", onAbort);
319
+ reject(error);
320
+ },
321
+ );
322
+ });
323
+ }
324
+
325
+ function createPreCommitAbortSignal(
326
+ source: AbortSignal | undefined,
327
+ isBeforeCommit: () => boolean,
328
+ ): { readonly signal: AbortSignal; readonly dispose: () => void } | undefined {
329
+ if (!source) return undefined;
330
+ const controller = new AbortController();
331
+ const onAbort = (): void => {
332
+ if (isBeforeCommit() && !controller.signal.aborted) controller.abort(preCommitAbortReason(source));
333
+ };
334
+ if (source.aborted) onAbort();
335
+ else source.addEventListener("abort", onAbort, { once: true });
336
+ return {
337
+ signal: controller.signal,
338
+ dispose: () => source.removeEventListener("abort", onAbort),
339
+ };
340
+ }
341
+
342
+ function preCommitAbortReason(source: AbortSignal): Error {
343
+ const reason = source.reason;
344
+ if (reason instanceof Error && reason.name !== "AbortError") return reason;
345
+ return new Error("LSP request cancelled before workspace edit commit");
346
+ }
347
+
348
+ function abortError(signal: AbortSignal): Error {
349
+ const reason = signal.reason;
350
+ if (reason instanceof Error) return reason;
351
+ const error = new Error(typeof reason === "string" ? reason : "operation cancelled");
352
+ error.name = "AbortError";
353
+ return error;
172
354
  }
@@ -1,8 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { delimiter, isAbsolute, join } from "node:path";
4
2
 
5
- import { contextCwd, contextEnv } from "../request-context.js";
3
+ import { lspRequestContext } from "../request-context.js";
6
4
  import { BUILTIN_SERVERS } from "./server-definitions.js";
7
5
  import type { ResolvedServer } from "./types.js";
8
6
 
@@ -27,27 +25,17 @@ export interface ServerWithSource extends ResolvedServer {
27
25
 
28
26
  export function getConfigPaths(): { project: string; user: string } {
29
27
  return {
30
- project: getProjectConfigPaths()[0] ?? join(process.cwd(), ".codex", "lsp-client.json"),
28
+ project: getProjectConfigPaths()[0] ?? lspRequestContext().projectConfigPaths[0] ?? "",
31
29
  user: getUserConfigPath(),
32
30
  };
33
31
  }
34
32
 
35
- function resolveProjectConfigPath(path: string): string {
36
- return isAbsolute(path) ? path : join(contextCwd(), path);
37
- }
38
-
39
- function getProjectConfigPaths(): string[] {
40
- const projectOverride = contextEnv("LSP_TOOLS_MCP_PROJECT_CONFIG");
41
- if (projectOverride) {
42
- return projectOverride.split(delimiter).filter(Boolean).map(resolveProjectConfigPath);
43
- }
44
- return [join(contextCwd(), ".codex", "lsp-client.json")];
33
+ function getProjectConfigPaths(): readonly string[] {
34
+ return lspRequestContext().projectConfigPaths;
45
35
  }
46
36
 
47
37
  function getUserConfigPath(): string {
48
- const userOverride = contextEnv("LSP_TOOLS_MCP_USER_CONFIG");
49
- if (!userOverride) return join(homedir(), ".codex", "lsp-client.json");
50
- return isAbsolute(userOverride) ? userOverride : join(homedir(), userOverride);
38
+ return lspRequestContext().userConfigPath;
51
39
  }
52
40
 
53
41
  function loadJsonFile(path: string): ConfigJson | null {
@@ -2,12 +2,19 @@ import { pathToFileURL } from "node:url";
2
2
 
3
3
  import { LspClientTransport } from "./transport.js";
4
4
 
5
- const INITIALIZE_SETTLE_MS = 300;
5
+ interface InitializeCapabilities {
6
+ readonly diagnosticProvider?: unknown;
7
+ }
8
+
9
+ function supportsDiagnosticPull(capabilities: InitializeCapabilities | undefined): boolean {
10
+ if (capabilities === undefined) return false;
11
+ return Object.hasOwn(capabilities, "diagnosticProvider");
12
+ }
6
13
 
7
14
  export class LspClientConnection extends LspClientTransport {
8
15
  async initialize(): Promise<void> {
9
16
  const rootUri = pathToFileURL(this.root).href;
10
- await this.sendRequest(
17
+ const result = await this.sendRequest<{ readonly capabilities?: InitializeCapabilities }>(
11
18
  "initialize",
12
19
  {
13
20
  processId: process.pid,
@@ -24,7 +31,6 @@ export class LspClientConnection extends LspClientTransport {
24
31
  rename: {
25
32
  prepareSupport: true,
26
33
  prepareSupportDefaultBehavior: 1,
27
- honorsChangeAnnotations: true,
28
34
  },
29
35
  codeAction: {
30
36
  codeActionLiteralSupport: {
@@ -53,9 +59,10 @@ export class LspClientConnection extends LspClientTransport {
53
59
  symbol: {},
54
60
  workspaceFolders: true,
55
61
  configuration: true,
56
- applyEdit: true,
62
+ ...(this.hasWorkspaceApplyEditHandler() ? { applyEdit: true } : {}),
57
63
  workspaceEdit: {
58
64
  documentChanges: true,
65
+ resourceOperations: ["create", "rename", "delete"],
59
66
  },
60
67
  },
61
68
  },
@@ -63,11 +70,10 @@ export class LspClientConnection extends LspClientTransport {
63
70
  },
64
71
  { timeoutMs: this.initializeTimeoutMs },
65
72
  );
73
+ this.setDiagnosticPullSupported(supportsDiagnosticPull(result?.capabilities));
66
74
  await this.sendNotification("initialized");
67
75
  await this.sendNotification("workspace/didChangeConfiguration", {
68
76
  settings: { json: { validate: { enable: true } } },
69
77
  });
70
- // Some servers accept initialized before their diagnostics/indexing handlers are ready.
71
- await new Promise((r) => setTimeout(r, INITIALIZE_SETTLE_MS));
72
78
  }
73
79
  }
@@ -0,0 +1,104 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { describe, expect, it } from "bun:test";
6
+
7
+ import { aggregateDiagnosticsForDirectory } from "./directory-diagnostics.js";
8
+ import type { LspClient } from "./client.js";
9
+ import type { Diagnostic, ResolvedServer } from "./types.js";
10
+
11
+ describe("directory diagnostics", () => {
12
+ it("#given multiple files and one per-file failure #when directory diagnostics run #then work stays bounded and file failures stay structured", async () => {
13
+ const workspace = mkdtempSync(join(tmpdir(), "lsp-directory-diag-"));
14
+ const active: string[] = [];
15
+ let maxActive = 0;
16
+ const diagnosticsByFile = new Map<string, Diagnostic[] | Error>([
17
+ [join(workspace, "a.ts"), [diagnostic("a")]],
18
+ [join(workspace, "b.ts"), new Error("b failed")],
19
+ [join(workspace, "c.ts"), [diagnostic("c")]],
20
+ [join(workspace, "d.ts"), [diagnostic("d")]],
21
+ [join(workspace, "e.ts"), [diagnostic("e")]],
22
+ ]);
23
+ const client = {
24
+ async diagnostics(filePath: string) {
25
+ active.push(filePath);
26
+ maxActive = Math.max(maxActive, active.length);
27
+ await Promise.resolve();
28
+ active.splice(active.indexOf(filePath), 1);
29
+ const entry = diagnosticsByFile.get(filePath);
30
+ if (entry instanceof Error) throw entry;
31
+ return { items: entry ?? [] };
32
+ },
33
+ } as Pick<LspClient, "diagnostics"> as LspClient;
34
+ const server: ResolvedServer = { id: "typescript", command: ["typescript-language-server"], extensions: [".ts"], priority: 1 };
35
+ const manager = {
36
+ async getClient() {
37
+ return client;
38
+ },
39
+ releaseClient() {},
40
+ };
41
+
42
+ try {
43
+ const result = await aggregateDiagnosticsForDirectory(workspace, ".ts", "all", 4, {
44
+ manager: manager as never,
45
+ listFiles: () => [...diagnosticsByFile.keys()],
46
+ workspaceRoot: workspace,
47
+ server,
48
+ maxConcurrency: 2,
49
+ });
50
+
51
+ expect(maxActive).toBeLessThanOrEqual(2);
52
+ expect(result.fileFailures).toEqual([{ file: join(workspace, "b.ts"), error: "b failed" }]);
53
+ expect(result.totalDiagnostics).toBe(3);
54
+ expect(result.output).toContain("File processing errors:");
55
+ } finally {
56
+ rmSync(workspace, { recursive: true, force: true });
57
+ }
58
+ });
59
+
60
+ it("#given directory diagnostics are aborted after one file #when the worker loops #then no later file is scheduled", async () => {
61
+ const workspace = mkdtempSync(join(tmpdir(), "lsp-directory-diag-cancel-"));
62
+ const controller = new AbortController();
63
+ const visited: string[] = [];
64
+ const firstFile = join(workspace, "a.ts");
65
+ const files = [firstFile, join(workspace, "b.ts"), join(workspace, "c.ts")];
66
+ const client = {
67
+ async diagnostics(filePath: string) {
68
+ visited.push(filePath);
69
+ controller.abort();
70
+ return { items: [] };
71
+ },
72
+ } as Pick<LspClient, "diagnostics"> as LspClient;
73
+ const server: ResolvedServer = { id: "typescript", command: ["typescript-language-server"], extensions: [".ts"], priority: 1 };
74
+ const manager = {
75
+ async getClient() {
76
+ return client;
77
+ },
78
+ releaseClient() {},
79
+ };
80
+
81
+ try {
82
+ const result = await aggregateDiagnosticsForDirectory(workspace, ".ts", "all", 3, {
83
+ manager: manager as never,
84
+ listFiles: () => files,
85
+ workspaceRoot: workspace,
86
+ server,
87
+ maxConcurrency: 1,
88
+ signal: controller.signal,
89
+ });
90
+
91
+ expect(result.totalDiagnostics).toBe(0);
92
+ expect(visited).toEqual([firstFile]);
93
+ } finally {
94
+ rmSync(workspace, { recursive: true, force: true });
95
+ }
96
+ });
97
+ });
98
+
99
+ function diagnostic(message: string): Diagnostic {
100
+ return {
101
+ range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
102
+ message,
103
+ };
104
+ }