oh-my-opencode 5.0.0-beta.21 → 5.0.0-beta.23

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 (151) hide show
  1. package/dist/cli/index.js +290 -288
  2. package/dist/cli-node/index.js +290 -288
  3. package/dist/index.js +1675 -1657
  4. package/dist/skills/debugging/SKILL.md +2 -0
  5. package/dist/skills/debugging/references/methodology/00-setup.md +12 -0
  6. package/dist/skills/debugging/references/methodology/02-investigate.md +21 -0
  7. package/dist/skills/debugging/references/runtimes/go.md +15 -1
  8. package/dist/skills/debugging/references/runtimes/native-binary.md +38 -0
  9. package/dist/skills/debugging/references/runtimes/node.md +25 -0
  10. package/dist/skills/debugging/references/runtimes/python.md +13 -0
  11. package/dist/skills/debugging/references/runtimes/rust.md +41 -0
  12. package/dist/skills/debugging/references/scripts/dap.mjs +267 -0
  13. package/dist/skills/debugging/references/scripts/dap.test.ts +86 -0
  14. package/dist/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
  15. package/dist/skills/debugging/references/tools/dap.md +103 -0
  16. package/dist/skills/debugging/references/tools/frida.md +193 -0
  17. package/dist/tools/delegate-task/types.d.ts +3 -0
  18. package/dist/tui.js +131 -147
  19. package/package.json +18 -15
  20. package/packages/lsp-core/src/index.ts +1 -0
  21. package/packages/lsp-core/src/lsp/client-wrapper.ts +25 -5
  22. package/packages/lsp-core/src/lsp/client.ts +26 -0
  23. package/packages/lsp-core/src/lsp/connection.ts +16 -0
  24. package/packages/lsp-core/src/lsp/constants.ts +1 -0
  25. package/packages/lsp-core/src/lsp/format-document.test.ts +143 -0
  26. package/packages/lsp-core/src/lsp/format-document.ts +90 -0
  27. package/packages/lsp-core/src/lsp/manager-max-clients.test.ts +151 -0
  28. package/packages/lsp-core/src/lsp/manager.ts +34 -1
  29. package/packages/lsp-core/src/lsp/server-definitions.ts +29 -0
  30. package/packages/lsp-core/src/lsp/server-installation.test.ts +376 -0
  31. package/packages/lsp-core/src/lsp/server-installation.ts +200 -27
  32. package/packages/lsp-core/src/lsp/server-resolution-local-binary.test.ts +170 -0
  33. package/packages/lsp-core/src/lsp/server-resolution.ts +18 -5
  34. package/packages/lsp-core/src/lsp/transport.ts +9 -0
  35. package/packages/lsp-core/src/lsp/types.ts +8 -0
  36. package/packages/lsp-core/src/tool-surface.test.ts +13 -1
  37. package/packages/lsp-core/src/tools/definitions.ts +14 -0
  38. package/packages/lsp-core/src/tools/format.test.ts +56 -0
  39. package/packages/lsp-core/src/tools/format.ts +56 -0
  40. package/packages/lsp-core/src/tools/index.ts +1 -0
  41. package/packages/lsp-core/src/tools/types.ts +10 -0
  42. package/packages/lsp-daemon/dist/cli.js +432 -144
  43. package/packages/lsp-daemon/dist/client.d.ts +10 -0
  44. package/packages/lsp-daemon/dist/client.js +401 -107
  45. package/packages/lsp-daemon/dist/daemon-client.d.ts +1 -0
  46. package/packages/lsp-daemon/dist/daemon-client.js +3 -0
  47. package/packages/lsp-daemon/dist/index.js +395 -107
  48. package/packages/lsp-daemon/package.json +1 -1
  49. package/packages/lsp-tools-mcp/dist/cli.js +384 -99
  50. package/packages/lsp-tools-mcp/dist/lsp/manager.js +53 -0
  51. package/packages/lsp-tools-mcp/dist/mcp.js +384 -99
  52. package/packages/lsp-tools-mcp/dist/tools.js +385 -99
  53. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  54. package/packages/omo-codex/plugin/components/bootstrap/dist/cli.js +2 -2
  55. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  56. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  57. package/packages/omo-codex/plugin/components/codegraph/dist/cli.js +69 -35
  58. package/packages/omo-codex/plugin/components/codegraph/dist/serve.js +21 -2
  59. package/packages/omo-codex/plugin/components/codegraph/package.json +1 -1
  60. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  61. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  62. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  63. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  64. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  65. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  66. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +3 -3
  67. package/packages/omo-codex/plugin/components/lsp/dist/cli.js +404 -121
  68. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  69. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  70. package/packages/omo-codex/plugin/components/rules/dist/cli.js +3 -3
  71. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  72. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  73. package/packages/omo-codex/plugin/components/rules/src/post-compact-budget.ts +3 -3
  74. package/packages/omo-codex/plugin/components/rules/test/post-compact-budget.test.ts +4 -4
  75. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  76. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  77. package/packages/omo-codex/plugin/components/telemetry/dist/cli.js +30 -15
  78. package/packages/omo-codex/plugin/components/telemetry/dist/posthog.js +30 -15
  79. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  80. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  81. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  82. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  83. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +2 -2
  84. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  85. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-commands.js +5 -1
  86. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.d.ts +2 -1
  87. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli-output.js +11 -1
  88. package/packages/omo-codex/plugin/components/ulw-loop/dist/cli.js +22 -10
  89. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +4 -4
  90. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  91. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-commands.ts +5 -1
  92. package/packages/omo-codex/plugin/components/ulw-loop/src/cli-output.ts +13 -1
  93. package/packages/omo-codex/plugin/components/ulw-loop/src/cli.ts +3 -5
  94. package/packages/omo-codex/plugin/components/ulw-loop/test/cli-entrypoint.test.ts +26 -2
  95. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  96. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  97. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  98. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-codegraph-init-guidance.json +1 -1
  99. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  100. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  101. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  102. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  103. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  104. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  105. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  106. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  107. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  108. package/packages/omo-codex/plugin/hooks/session-start-checking-codegraph-bootstrap.json +1 -1
  109. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  110. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  111. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  112. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  113. package/packages/omo-codex/plugin/hooks/subagent-stop-checking-ulw-execute-continuation.json +1 -1
  114. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  115. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  116. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  118. package/packages/omo-codex/plugin/model-catalog.json +3 -3
  119. package/packages/omo-codex/plugin/package-lock.json +13 -13
  120. package/packages/omo-codex/plugin/package.json +1 -1
  121. package/packages/omo-codex/plugin/scripts/migrate-codex-config/catalog.mjs +3 -3
  122. package/packages/omo-codex/plugin/skills/debugging/SKILL.md +2 -0
  123. package/packages/omo-codex/plugin/skills/debugging/references/methodology/00-setup.md +12 -0
  124. package/packages/omo-codex/plugin/skills/debugging/references/methodology/02-investigate.md +21 -0
  125. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/go.md +15 -1
  126. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/native-binary.md +38 -0
  127. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/node.md +25 -0
  128. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/python.md +13 -0
  129. package/packages/omo-codex/plugin/skills/debugging/references/runtimes/rust.md +41 -0
  130. package/packages/omo-codex/plugin/skills/debugging/references/scripts/dap.mjs +267 -0
  131. package/packages/omo-codex/plugin/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
  132. package/packages/omo-codex/plugin/skills/debugging/references/tools/dap.md +103 -0
  133. package/packages/omo-codex/plugin/skills/debugging/references/tools/frida.md +193 -0
  134. package/packages/omo-codex/plugin/test/aggregate-model-catalog.test.mjs +1 -1
  135. package/packages/omo-codex/plugin/test/auto-update.test.mjs +2 -2
  136. package/packages/omo-codex/plugin/test/migrate-codex-config.test.mjs +8 -8
  137. package/packages/omo-codex/scripts/install-config-reasoning.test.mjs +2 -2
  138. package/packages/omo-codex/scripts/install-dist/install-local.mjs +38 -23
  139. package/packages/shared-skills/skills/debugging/SKILL.md +2 -0
  140. package/packages/shared-skills/skills/debugging/references/methodology/00-setup.md +12 -0
  141. package/packages/shared-skills/skills/debugging/references/methodology/02-investigate.md +21 -0
  142. package/packages/shared-skills/skills/debugging/references/runtimes/go.md +15 -1
  143. package/packages/shared-skills/skills/debugging/references/runtimes/native-binary.md +38 -0
  144. package/packages/shared-skills/skills/debugging/references/runtimes/node.md +25 -0
  145. package/packages/shared-skills/skills/debugging/references/runtimes/python.md +13 -0
  146. package/packages/shared-skills/skills/debugging/references/runtimes/rust.md +41 -0
  147. package/packages/shared-skills/skills/debugging/references/scripts/dap.mjs +267 -0
  148. package/packages/shared-skills/skills/debugging/references/scripts/dap.test.ts +86 -0
  149. package/packages/shared-skills/skills/debugging/references/scripts/fixture-adapter.mjs +59 -0
  150. package/packages/shared-skills/skills/debugging/references/tools/dap.md +103 -0
  151. package/packages/shared-skills/skills/debugging/references/tools/frida.md +193 -0
@@ -17,6 +17,7 @@ import {
17
17
  } from "./errors.js";
18
18
  import { getLspManager, type LspManager } from "./manager.js";
19
19
  import { findWorkspaceRootOutsideContext } from "./outside-context-workspace.js";
20
+ import { LSP_LOCAL_INSTALL_HINTS } from "./server-definitions.js";
20
21
  import { loadInstallDecision } from "./server-install-state.js";
21
22
  import { findServerForExtension } from "./server-resolution.js";
22
23
  import type { ServerLookupResult } from "./types.js";
@@ -138,6 +139,26 @@ export function formatServerLookupError(result: Exclude<ServerLookupResult, { st
138
139
  ].join("\n");
139
140
  }
140
141
 
142
+ /**
143
+ * Builds the install guidance, offering the repo-local install first.
144
+ *
145
+ * A repo-local install is resolvable without touching the global environment, so it is the
146
+ * recommended route whenever the server ships as a project dependency.
147
+ */
148
+ function formatInstallOptions(serverId: string, installHint: string): string[] {
149
+ const localHint = LSP_LOCAL_INSTALL_HINTS[serverId];
150
+ if (localHint === undefined) {
151
+ return ["To install, run:", ` ${installHint}`];
152
+ }
153
+ return [
154
+ "To install in THIS repository (preferred — no global install needed):",
155
+ ` ${localHint}`,
156
+ "",
157
+ "Or install it globally:",
158
+ ` ${installHint}`,
159
+ ];
160
+ }
161
+
141
162
  function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_installed" }>): string {
142
163
  const { server, installHint } = result;
143
164
  const extensions = server.extensions.join(", ");
@@ -154,20 +175,20 @@ function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_i
154
175
  `Command not found: ${server.command[0]}`,
155
176
  "",
156
177
  ];
178
+ const installOptions = formatInstallOptions(server.id, installHint);
157
179
 
158
180
  if (decision === "allowed") {
159
181
  return [
160
182
  ...header,
161
183
  "The user has pre-authorized LSP installation. Run the install command, then retry this tool:",
162
- ` ${installHint}`,
184
+ ...installOptions.slice(1),
163
185
  ].join("\n");
164
186
  }
165
187
 
166
188
  if (!context.capabilities.installDecisionTool) {
167
189
  return [
168
190
  ...header,
169
- "To install, run:",
170
- ` ${installHint}`,
191
+ ...installOptions,
171
192
  "",
172
193
  "ACTION REQUIRED — ASK THE USER whether to install this LSP server.",
173
194
  "Install-decision recording is unavailable in this harness; proceed without LSP if the user declines.",
@@ -176,8 +197,7 @@ function formatNotInstalled(result: Extract<ServerLookupResult, { status: "not_i
176
197
 
177
198
  return [
178
199
  ...header,
179
- "To install, run:",
180
- ` ${installHint}`,
200
+ ...installOptions,
181
201
  "",
182
202
  "ACTION REQUIRED — ASK THE USER whether to install this LSP server.",
183
203
  "- If the user agrees: run the install command above, then retry this tool.",
@@ -6,6 +6,7 @@ import type { LspClientTimeoutOptions } from "./transport.js";
6
6
  import type {
7
7
  Diagnostic,
8
8
  DocumentSymbol,
9
+ FormattingOptions,
9
10
  Location,
10
11
  LocationLink,
11
12
  PrepareRenameDefaultBehavior,
@@ -13,6 +14,7 @@ import type {
13
14
  Range,
14
15
  ResolvedServer,
15
16
  SymbolInfo,
17
+ TextEdit,
16
18
  WorkspaceEdit,
17
19
  } from "./types.js";
18
20
  import { LspRequestTimeoutError } from "./errors.js";
@@ -133,6 +135,30 @@ export class LspClient extends LspClientConnection {
133
135
  }, options);
134
136
  }
135
137
 
138
+ /**
139
+ * Requests whole-document formatting edits, returning null when the server never advertised the
140
+ * capability so callers can report unavailability instead of surfacing a method-not-found error.
141
+ */
142
+ async formatDocument(
143
+ filePath: string,
144
+ options: FormattingOptions,
145
+ signal?: AbortSignal,
146
+ ): Promise<TextEdit[] | null> {
147
+ if (!this.isDocumentFormattingSupported()) return null;
148
+ const absPath = this.resolveWorkspacePath(filePath);
149
+ await this.openFile(absPath);
150
+ const requestOptions = signal === undefined ? {} : { signal };
151
+ const edits = await this.sendRequest<TextEdit[] | null>(
152
+ "textDocument/formatting",
153
+ {
154
+ textDocument: { uri: pathToFileURL(absPath).href },
155
+ options,
156
+ },
157
+ requestOptions,
158
+ );
159
+ return edits ?? [];
160
+ }
161
+
136
162
  async workspaceSymbols(query: string, signal?: AbortSignal): Promise<SymbolInfo[]> {
137
163
  const options = signal === undefined ? {} : { signal };
138
164
  return this.sendRequest<SymbolInfo[]>("workspace/symbol", { query }, options);
@@ -4,6 +4,7 @@ import { LspClientTransport } from "./transport.js";
4
4
 
5
5
  interface InitializeCapabilities {
6
6
  readonly diagnosticProvider?: unknown;
7
+ readonly documentFormattingProvider?: unknown;
7
8
  }
8
9
 
9
10
  function supportsDiagnosticPull(capabilities: InitializeCapabilities | undefined): boolean {
@@ -11,6 +12,19 @@ function supportsDiagnosticPull(capabilities: InitializeCapabilities | undefined
11
12
  return Object.hasOwn(capabilities, "diagnosticProvider");
12
13
  }
13
14
 
15
+ /**
16
+ * Reads the whole-document formatting capability the server advertised at initialize time.
17
+ *
18
+ * The provider is either a boolean or an options object, and an explicit `false` means the
19
+ * server refuses the request, so both shapes are checked instead of mere key presence.
20
+ */
21
+ function supportsDocumentFormatting(capabilities: InitializeCapabilities | undefined): boolean {
22
+ if (capabilities === undefined) return false;
23
+ const provider = capabilities.documentFormattingProvider;
24
+ if (provider === undefined || provider === null || provider === false) return false;
25
+ return provider === true || typeof provider === "object";
26
+ }
27
+
14
28
  export class LspClientConnection extends LspClientTransport {
15
29
  async initialize(): Promise<void> {
16
30
  const rootUri = pathToFileURL(this.root).href;
@@ -28,6 +42,7 @@ export class LspClientConnection extends LspClientTransport {
28
42
  references: {},
29
43
  documentSymbol: { hierarchicalDocumentSymbolSupport: true },
30
44
  publishDiagnostics: {},
45
+ formatting: { dynamicRegistration: false },
31
46
  rename: {
32
47
  prepareSupport: true,
33
48
  prepareSupportDefaultBehavior: 1,
@@ -71,6 +86,7 @@ export class LspClientConnection extends LspClientTransport {
71
86
  { timeoutMs: this.initializeTimeoutMs },
72
87
  );
73
88
  this.setDiagnosticPullSupported(supportsDiagnosticPull(result?.capabilities));
89
+ this.setDocumentFormattingSupported(supportsDocumentFormatting(result?.capabilities));
74
90
  await this.sendNotification("initialized", {});
75
91
  await this.sendNotification("workspace/didChangeConfiguration", {
76
92
  settings: { json: { validate: { enable: true } } },
@@ -6,6 +6,7 @@ export const DEFAULT_MAX_DIRECTORY_FILES = 50;
6
6
  export const REQUEST_TIMEOUT_MS = 15_000;
7
7
  export const INIT_TIMEOUT_MS = 60_000;
8
8
  export const IDLE_TIMEOUT_MS = 5 * 60_000;
9
+ export const MAX_RESIDENT_CLIENTS = 6;
9
10
  export const REAPER_INTERVAL_MS = 60_000;
10
11
  export const STOP_HARD_KILL_TIMEOUT_MS = 5_000;
11
12
  export const STOP_SIGKILL_GRACE_MS = 1_000;
@@ -0,0 +1,143 @@
1
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ import { afterEach, describe, expect, it } from "bun:test";
6
+
7
+ import type { LspClient } from "./client.js";
8
+ import { formatDocumentWithClient } from "./format-document.js";
9
+ import type { TextEdit } from "./types.js";
10
+
11
+ const workspaces: string[] = [];
12
+
13
+ afterEach(() => {
14
+ for (const workspace of workspaces.splice(0)) rmSync(workspace, { recursive: true, force: true });
15
+ });
16
+
17
+ function workspaceWith(fileName: string, content: string): { readonly root: string; readonly filePath: string } {
18
+ const root = mkdtempSync(join(tmpdir(), "lsp-format-"));
19
+ workspaces.push(root);
20
+ const filePath = join(root, fileName);
21
+ writeFileSync(filePath, content, "utf-8");
22
+ return { root, filePath };
23
+ }
24
+
25
+ function stubClient(edits: TextEdit[] | null): LspClient {
26
+ return {
27
+ async formatDocument() {
28
+ return edits;
29
+ },
30
+ async openFile() {},
31
+ } as Pick<LspClient, "formatDocument" | "openFile"> as LspClient;
32
+ }
33
+
34
+ describe("formatDocumentWithClient", () => {
35
+ it("#given a server returning text edits #when the document is formatted #then the file is rewritten and line deltas are reported", async () => {
36
+ // given
37
+ const { filePath } = workspaceWith("sample.ts", "const a=1\nconst b =2\nconst c=3\n");
38
+ const edits: TextEdit[] = [
39
+ { range: { start: { line: 0, character: 0 }, end: { line: 1, character: 12 } }, newText: "const a = 1;\nconst b = 2;" },
40
+ ];
41
+
42
+ // when
43
+ const result = await formatDocumentWithClient(stubClient(edits), filePath);
44
+
45
+ // then
46
+ expect(result.status).toBe("formatted");
47
+ expect(readFileSync(filePath, "utf-8")).toBe("const a = 1;\nconst b = 2;\nconst c=3\n");
48
+ expect(result).toMatchObject({ status: "formatted", linesAdded: 2, linesRemoved: 2 });
49
+ });
50
+
51
+ it("#given a server returning an edit that adds lines #when the document is formatted #then the added and removed line counts differ", async () => {
52
+ // given
53
+ const { filePath } = workspaceWith("sample.ts", "const a=1;const b=2\n");
54
+ const edits: TextEdit[] = [
55
+ { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 19 } }, newText: "const a = 1;\nconst b = 2;" },
56
+ ];
57
+
58
+ // when
59
+ const result = await formatDocumentWithClient(stubClient(edits), filePath);
60
+
61
+ // then
62
+ expect(result).toMatchObject({ status: "formatted", linesAdded: 2, linesRemoved: 1 });
63
+ expect(readFileSync(filePath, "utf-8")).toBe("const a = 1;\nconst b = 2;\n");
64
+ });
65
+
66
+ it("#given a server that does not advertise document formatting #when the document is formatted #then the result is unavailable and the file bytes are untouched", async () => {
67
+ // given
68
+ const original = "const a=1\n";
69
+ const { filePath } = workspaceWith("sample.ts", original);
70
+ const before = statSync(filePath);
71
+
72
+ // when
73
+ const result = await formatDocumentWithClient(stubClient(null), filePath);
74
+
75
+ // then
76
+ expect(result).toEqual({ status: "unavailable", reason: "capability_not_advertised" });
77
+ expect(readFileSync(filePath)).toEqual(Buffer.from(original));
78
+ expect(statSync(filePath).mtimeMs).toBe(before.mtimeMs);
79
+ });
80
+
81
+ it("#given a server returning no edits #when the document is formatted #then the result is unchanged and the file bytes are untouched", async () => {
82
+ // given
83
+ const original = "const a = 1;\n";
84
+ const { filePath } = workspaceWith("sample.ts", original);
85
+
86
+ // when
87
+ const result = await formatDocumentWithClient(stubClient([]), filePath);
88
+
89
+ // then
90
+ expect(result).toEqual({ status: "unchanged", linesAdded: 0, linesRemoved: 0 });
91
+ expect(readFileSync(filePath)).toEqual(Buffer.from(original));
92
+ });
93
+
94
+ it("#given edits that reproduce the current bytes #when the document is formatted #then the result is unchanged", async () => {
95
+ // given
96
+ const original = "const a = 1;\n";
97
+ const { filePath } = workspaceWith("sample.ts", original);
98
+ const edits: TextEdit[] = [
99
+ { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 12 } }, newText: "const a = 1;" },
100
+ ];
101
+
102
+ // when
103
+ const result = await formatDocumentWithClient(stubClient(edits), filePath);
104
+
105
+ // then
106
+ expect(result.status).toBe("unchanged");
107
+ expect(readFileSync(filePath, "utf-8")).toBe(original);
108
+ });
109
+
110
+ it("#given the file shrinks after the server computed its edits #when the edits are applied #then the stale edit is rejected instead of corrupting the file", async () => {
111
+ // given
112
+ const { filePath } = workspaceWith("sample.ts", "const a=1\nconst b=2\n");
113
+ const staleEdits: TextEdit[] = [
114
+ { range: { start: { line: 1, character: 0 }, end: { line: 1, character: 9 } }, newText: "const b = 2;" },
115
+ ];
116
+ const racingClient = {
117
+ async formatDocument() {
118
+ writeFileSync(filePath, "short\n", "utf-8");
119
+ return staleEdits;
120
+ },
121
+ async openFile() {},
122
+ } as Pick<LspClient, "formatDocument" | "openFile"> as LspClient;
123
+
124
+ // when / then
125
+ await expect(formatDocumentWithClient(racingClient, filePath)).rejects.toThrow(/outside line/);
126
+ expect(readFileSync(filePath, "utf-8")).toBe("short\n");
127
+ });
128
+
129
+ it("#given a formatted document #when the write completes #then no temporary file is left behind", async () => {
130
+ // given
131
+ const { root, filePath } = workspaceWith("sample.ts", "const a=1\n");
132
+ const edits: TextEdit[] = [
133
+ { range: { start: { line: 0, character: 0 }, end: { line: 0, character: 9 } }, newText: "const a = 1;" },
134
+ ];
135
+
136
+ // when
137
+ await formatDocumentWithClient(stubClient(edits), filePath);
138
+
139
+ // then
140
+ expect(readFileSync(filePath, "utf-8")).toBe("const a = 1;\n");
141
+ expect(() => statSync(join(root, "sample.ts.tmp"))).toThrow();
142
+ });
143
+ });
@@ -0,0 +1,90 @@
1
+ import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+
3
+ import type { LspClient } from "./client.js";
4
+ import type { FormattingOptions, TextEdit } from "./types.js";
5
+ import { normalizeTextEdits } from "./workspace-edit-text.js";
6
+
7
+ export const DEFAULT_FORMATTING_OPTIONS: FormattingOptions = {
8
+ tabSize: 4,
9
+ insertSpaces: false,
10
+ trimTrailingWhitespace: true,
11
+ insertFinalNewline: true,
12
+ trimFinalNewlines: true,
13
+ };
14
+
15
+ export type FormatDocumentResult =
16
+ | { readonly status: "formatted"; readonly linesAdded: number; readonly linesRemoved: number }
17
+ | { readonly status: "unchanged"; readonly linesAdded: 0; readonly linesRemoved: 0 }
18
+ | { readonly status: "unavailable"; readonly reason: "capability_not_advertised" };
19
+
20
+ export interface FormatDocumentOptions {
21
+ readonly formattingOptions?: FormattingOptions;
22
+ readonly signal?: AbortSignal;
23
+ }
24
+
25
+ const UNCHANGED: FormatDocumentResult = { status: "unchanged", linesAdded: 0, linesRemoved: 0 };
26
+
27
+ /**
28
+ * Formats one document through the resident language server and commits the result to disk.
29
+ *
30
+ * The server is asked for whole-document edits rather than formatted text, so the edits are
31
+ * validated and applied with the same normalization the workspace-edit path uses. Nothing is
32
+ * written when the server declines the capability or when the edits reproduce the current bytes,
33
+ * which keeps a no-op format from touching file mtimes that downstream tooling watches.
34
+ */
35
+ export async function formatDocumentWithClient(
36
+ client: LspClient,
37
+ filePath: string,
38
+ options: FormatDocumentOptions = {},
39
+ ): Promise<FormatDocumentResult> {
40
+ const edits = await client.formatDocument(
41
+ filePath,
42
+ options.formattingOptions ?? DEFAULT_FORMATTING_OPTIONS,
43
+ options.signal,
44
+ );
45
+ if (edits === null) return { status: "unavailable", reason: "capability_not_advertised" };
46
+ if (edits.length === 0) return UNCHANGED;
47
+
48
+ const before = readFileSync(filePath, "utf-8");
49
+ const normalized = normalizeTextEdits(before, edits, 0);
50
+ if (normalized.text === before) return UNCHANGED;
51
+
52
+ writeAtomically(filePath, normalized.text);
53
+ await client.openFile(filePath);
54
+
55
+ return {
56
+ status: "formatted",
57
+ ...lineDelta(normalized.edits),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Counts the lines each edit removes from the document and adds back in its place.
63
+ *
64
+ * Counting from the edits instead of diffing the whole file keeps the report proportional to what
65
+ * the server actually rewrote, so an unrelated line never shows up as churn.
66
+ */
67
+ function lineDelta(edits: readonly TextEdit[]): { readonly linesAdded: number; readonly linesRemoved: number } {
68
+ let linesAdded = 0;
69
+ let linesRemoved = 0;
70
+ for (const edit of edits) {
71
+ linesRemoved += edit.range.end.line - edit.range.start.line + 1;
72
+ linesAdded += edit.newText.split("\n").length;
73
+ }
74
+ return { linesAdded, linesRemoved };
75
+ }
76
+
77
+ function writeAtomically(filePath: string, content: string): void {
78
+ const tempPath = `${filePath}.omo-format.tmp`;
79
+ writeFileSync(tempPath, content, "utf-8");
80
+ try {
81
+ renameSync(tempPath, filePath);
82
+ } catch (error) {
83
+ try {
84
+ unlinkSync(tempPath);
85
+ } catch {
86
+ // The rename failure is the actionable error; a leftover temp file must not mask it.
87
+ }
88
+ throw error;
89
+ }
90
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import type { LspClient } from "./client.js";
4
+ import { LspManager } from "./manager.js";
5
+ import type { ResolvedServer } from "./types.js";
6
+
7
+ const SERVER: ResolvedServer = {
8
+ id: "typescript",
9
+ command: ["typescript-language-server", "--stdio"],
10
+ extensions: [".ts"],
11
+ priority: 1,
12
+ };
13
+
14
+ class FakeLspClient {
15
+ alive = true;
16
+ stopCallCount = 0;
17
+
18
+ constructor(readonly root: string) {}
19
+
20
+ async start(): Promise<void> {}
21
+
22
+ async initialize(): Promise<void> {}
23
+
24
+ isAlive(): boolean {
25
+ return this.alive;
26
+ }
27
+
28
+ command(): string[] {
29
+ return [...SERVER.command];
30
+ }
31
+
32
+ async stop(): Promise<void> {
33
+ this.stopCallCount += 1;
34
+ this.alive = false;
35
+ }
36
+ }
37
+
38
+ function managerWithFakes(maxResidentClients: number): {
39
+ readonly manager: LspManager;
40
+ readonly clients: Map<string, FakeLspClient>;
41
+ readonly clock: { value: number };
42
+ } {
43
+ const clients = new Map<string, FakeLspClient>();
44
+ const clock = { value: 1_000 };
45
+ const manager = new LspManager({
46
+ maxResidentClients,
47
+ reaperIntervalMs: 60_000,
48
+ idleTimeoutMs: 60_000_000,
49
+ now: () => clock.value,
50
+ clientFactory: (root) => {
51
+ const client = new FakeLspClient(root);
52
+ clients.set(root, client);
53
+ return client as unknown as LspClient;
54
+ },
55
+ });
56
+ return { manager, clients, clock };
57
+ }
58
+
59
+ describe("LspManager resident client cap", () => {
60
+ it("#given a cap of two idle clients #when a third client is admitted #then the least recently used one is stopped and evicted", async () => {
61
+ // given
62
+ const { manager, clients, clock } = managerWithFakes(2);
63
+ try {
64
+ await manager.getClient("/root-a", SERVER);
65
+ manager.releaseClient("/root-a", SERVER.id);
66
+ clock.value += 10;
67
+ await manager.getClient("/root-b", SERVER);
68
+ manager.releaseClient("/root-b", SERVER.id);
69
+ clock.value += 10;
70
+
71
+ // when
72
+ await manager.getClient("/root-c", SERVER);
73
+ manager.releaseClient("/root-c", SERVER.id);
74
+
75
+ // then
76
+ expect(manager.clientCount()).toBe(2);
77
+ expect(manager.hasClient("/root-a", SERVER.id)).toBe(false);
78
+ expect(manager.hasClient("/root-b", SERVER.id)).toBe(true);
79
+ expect(manager.hasClient("/root-c", SERVER.id)).toBe(true);
80
+ expect(clients.get("/root-a")?.stopCallCount).toBe(1);
81
+ } finally {
82
+ await manager.stopAll();
83
+ }
84
+ });
85
+
86
+ it("#given the least recently used client is mid-request #when a new client is admitted #then the busy client survives and an idle one is evicted", async () => {
87
+ // given
88
+ const { manager, clients, clock } = managerWithFakes(2);
89
+ try {
90
+ await manager.getClient("/root-busy", SERVER);
91
+ clock.value += 10;
92
+ await manager.getClient("/root-idle", SERVER);
93
+ manager.releaseClient("/root-idle", SERVER.id);
94
+ clock.value += 10;
95
+
96
+ // when
97
+ await manager.getClient("/root-new", SERVER);
98
+ manager.releaseClient("/root-new", SERVER.id);
99
+
100
+ // then
101
+ expect(manager.hasClient("/root-busy", SERVER.id)).toBe(true);
102
+ expect(manager.hasClient("/root-idle", SERVER.id)).toBe(false);
103
+ expect(clients.get("/root-busy")?.stopCallCount).toBe(0);
104
+ expect(clients.get("/root-idle")?.stopCallCount).toBe(1);
105
+ } finally {
106
+ manager.releaseClient("/root-busy", SERVER.id);
107
+ await manager.stopAll();
108
+ }
109
+ });
110
+
111
+ it("#given every resident client is busy #when a new client is admitted #then nothing is evicted and the cap is exceeded rather than breaking a live request", async () => {
112
+ // given
113
+ const { manager, clients } = managerWithFakes(1);
114
+ try {
115
+ await manager.getClient("/root-busy", SERVER);
116
+
117
+ // when
118
+ await manager.getClient("/root-second", SERVER);
119
+
120
+ // then
121
+ expect(manager.clientCount()).toBe(2);
122
+ expect(clients.get("/root-busy")?.stopCallCount).toBe(0);
123
+ } finally {
124
+ manager.releaseClient("/root-busy", SERVER.id);
125
+ manager.releaseClient("/root-second", SERVER.id);
126
+ await manager.stopAll();
127
+ }
128
+ });
129
+
130
+ it("#given warmup requests beyond the cap #when a warm client is admitted #then the idle least recently used client is evicted too", async () => {
131
+ // given
132
+ const { manager, clients, clock } = managerWithFakes(1);
133
+ try {
134
+ await manager.getClient("/root-first", SERVER);
135
+ manager.releaseClient("/root-first", SERVER.id);
136
+ clock.value += 10;
137
+
138
+ // when
139
+ manager.warmupClient("/root-warm", SERVER);
140
+ await Promise.resolve();
141
+ await Promise.resolve();
142
+
143
+ // then
144
+ expect(manager.hasClient("/root-first", SERVER.id)).toBe(false);
145
+ expect(clients.get("/root-first")?.stopCallCount).toBe(1);
146
+ expect(manager.hasClient("/root-warm", SERVER.id)).toBe(true);
147
+ } finally {
148
+ await manager.stopAll();
149
+ }
150
+ });
151
+ });
@@ -1,6 +1,6 @@
1
1
  import { reportBestEffortCleanupError } from "./cleanup-errors.js";
2
2
  import { LspClient } from "./client.js";
3
- import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, REAPER_INTERVAL_MS } from "./constants.js";
3
+ import { IDLE_TIMEOUT_MS, INIT_TIMEOUT_MS, MAX_RESIDENT_CLIENTS, REAPER_INTERVAL_MS } from "./constants.js";
4
4
  import { installProcessSignalCleanup } from "./process-signal-cleanup.js";
5
5
  import type { ResolvedServer } from "./types.js";
6
6
 
@@ -28,6 +28,7 @@ export interface ClientSnapshot {
28
28
  export interface LspManagerOptions {
29
29
  idleTimeoutMs?: number;
30
30
  initTimeoutMs?: number;
31
+ maxResidentClients?: number;
31
32
  reaperIntervalMs?: number;
32
33
  clientFactory?: (root: string, server: ResolvedServer) => LspClient;
33
34
  now?: () => number;
@@ -80,6 +81,7 @@ export class LspManager {
80
81
 
81
82
  private readonly idleTimeoutMs: number;
82
83
  private readonly initTimeoutMs: number;
84
+ private readonly maxResidentClients: number;
83
85
  private readonly reaperIntervalMs: number;
84
86
  private readonly clientFactory: (root: string, server: ResolvedServer) => LspClient;
85
87
  private readonly now: () => number;
@@ -87,6 +89,7 @@ export class LspManager {
87
89
  constructor(options: LspManagerOptions = {}) {
88
90
  this.idleTimeoutMs = options.idleTimeoutMs ?? IDLE_TIMEOUT_MS;
89
91
  this.initTimeoutMs = options.initTimeoutMs ?? INIT_TIMEOUT_MS;
92
+ this.maxResidentClients = options.maxResidentClients ?? MAX_RESIDENT_CLIENTS;
90
93
  this.reaperIntervalMs = options.reaperIntervalMs ?? REAPER_INTERVAL_MS;
91
94
  this.clientFactory = options.clientFactory ?? ((root, server) => new LspClient(root, server));
92
95
  this.now = options.now ?? (() => Date.now());
@@ -134,6 +137,32 @@ export class LspManager {
134
137
  }
135
138
  }
136
139
 
140
+ /**
141
+ * Frees capacity for one new client by stopping the least recently used idle client.
142
+ *
143
+ * Only clients with no in-flight work are eligible, so a resident server is never torn down
144
+ * underneath a live request; when every resident client is busy the cap is exceeded instead.
145
+ */
146
+ private evictForAdmission(): void {
147
+ while (this.clients.size >= this.maxResidentClients) {
148
+ const victim = this.leastRecentlyUsedIdleClient();
149
+ if (!victim) return;
150
+ this.clients.delete(victim.key);
151
+ void stopClientBestEffort(victim.managed.client);
152
+ }
153
+ }
154
+
155
+ private leastRecentlyUsedIdleClient(): { readonly key: string; readonly managed: ManagedClient } | null {
156
+ let candidate: { readonly key: string; readonly managed: ManagedClient } | null = null;
157
+ for (const [key, managed] of this.clients) {
158
+ if (managed.refCount > 0 || managed.pendingWaiters > 0 || managed.isInitializing) continue;
159
+ if (candidate === null || managed.lastUsedAt < candidate.managed.lastUsedAt) {
160
+ candidate = { key, managed };
161
+ }
162
+ }
163
+ return candidate;
164
+ }
165
+
137
166
  private async tryDeleteIfOrphaned(key: string, managed: ManagedClient): Promise<void> {
138
167
  if (
139
168
  managed.refCount === 0 &&
@@ -197,6 +226,8 @@ export class LspManager {
197
226
  return managed.client;
198
227
  }
199
228
 
229
+ this.evictForAdmission();
230
+
200
231
  const client = this.clientFactory(root, server);
201
232
  const initStartedAt = this.now();
202
233
  const initPromise = (async () => {
@@ -264,6 +295,8 @@ export class LspManager {
264
295
  const key = this.getKey(root, server.id);
265
296
  if (this.clients.has(key)) return;
266
297
 
298
+ this.evictForAdmission();
299
+
267
300
  const client = this.clientFactory(root, server);
268
301
  const initStartedAt = this.now();
269
302
  const initPromise = (async () => {
@@ -49,6 +49,35 @@ export const LSP_INSTALL_HINTS: Record<string, string> = {
49
49
  "Install: dotnet tool install -g roslyn-language-server --prerelease (requires v5.8.0+). See https://github.com/dotnet/razor",
50
50
  };
51
51
 
52
+ /**
53
+ * Repo-local install commands, offered ahead of the global hints in `LSP_INSTALL_HINTS`.
54
+ *
55
+ * A devDependency (or project venv) is resolvable by `resolveServerBinary()` without any
56
+ * global install, and it pins the version the project actually expects. Only servers that
57
+ * are genuinely distributed per-project belong here; toolchain-wide servers (rust-analyzer,
58
+ * gopls, clangd, ...) keep their global hint alone.
59
+ */
60
+ export const LSP_LOCAL_INSTALL_HINTS: Record<string, string> = {
61
+ typescript: "bun add -d typescript-language-server typescript",
62
+ vue: "bun add -d @vue/language-server",
63
+ eslint: "bun add -d vscode-langservers-extracted",
64
+ oxlint: "bun add -d oxlint",
65
+ biome: "bun add -d @biomejs/biome",
66
+ svelte: "bun add -d svelte-language-server",
67
+ astro: "bun add -d @astrojs/language-server",
68
+ bash: "bun add -d bash-language-server",
69
+ "bash-ls": "bun add -d bash-language-server",
70
+ "yaml-ls": "bun add -d yaml-language-server",
71
+ php: "bun add -d intelephense",
72
+ prisma: "bun add -d prisma",
73
+ dockerfile: "bun add -d dockerfile-language-server-nodejs",
74
+ pyright: "uv add --dev pyright",
75
+ basedpyright: "uv add --dev basedpyright",
76
+ ruff: "uv add --dev ruff",
77
+ ty: "uv add --dev ty",
78
+ "ruby-lsp": "bundle add ruby-lsp --group development",
79
+ };
80
+
52
81
  export const BUILTIN_SERVERS: Record<string, Omit<LspServerConfig, "id">> = {
53
82
  typescript: {
54
83
  command: ["typescript-language-server", "--stdio"],