@cruxy/cli 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/cli/commands/run.js +12 -1
  2. package/dist/cli/session-factory.js +12 -0
  3. package/dist/config/schema.d.ts +127 -14
  4. package/dist/config/schema.js +39 -0
  5. package/dist/errors/constructors.d.ts +23 -0
  6. package/dist/errors/constructors.js +67 -0
  7. package/dist/errors/types.d.ts +10 -0
  8. package/dist/errors/types.js +17 -0
  9. package/dist/lsp/client.d.ts +25 -0
  10. package/dist/lsp/client.js +43 -0
  11. package/dist/lsp/index.d.ts +8 -0
  12. package/dist/lsp/index.js +8 -0
  13. package/dist/lsp/pool.d.ts +48 -0
  14. package/dist/lsp/pool.js +132 -0
  15. package/dist/lsp/registry.d.ts +38 -0
  16. package/dist/lsp/registry.js +133 -0
  17. package/dist/lsp/server.d.ts +48 -0
  18. package/dist/lsp/server.js +264 -0
  19. package/dist/lsp/service.d.ts +44 -0
  20. package/dist/lsp/service.js +76 -0
  21. package/dist/lsp/tools/common.d.ts +23 -0
  22. package/dist/lsp/tools/common.js +75 -0
  23. package/dist/lsp/tools/find-definition.d.ts +23 -0
  24. package/dist/lsp/tools/find-definition.js +41 -0
  25. package/dist/lsp/tools/find-references.d.ts +23 -0
  26. package/dist/lsp/tools/find-references.js +41 -0
  27. package/dist/lsp/tools/get-diagnostics.d.ts +17 -0
  28. package/dist/lsp/tools/get-diagnostics.js +43 -0
  29. package/dist/lsp/tools/hover.d.ts +23 -0
  30. package/dist/lsp/tools/hover.js +38 -0
  31. package/dist/lsp/tools/index.d.ts +4 -0
  32. package/dist/lsp/tools/index.js +4 -0
  33. package/dist/lsp/transport.d.ts +48 -0
  34. package/dist/lsp/transport.js +264 -0
  35. package/dist/lsp/types.d.ts +107 -0
  36. package/dist/lsp/types.js +1 -0
  37. package/dist/tools/file/grep-files.d.ts +2 -2
  38. package/package.json +1 -1
@@ -11,6 +11,7 @@ import { buildHooksService } from "../../hooks/index.js";
11
11
  import { runInteractive } from "../repl.js";
12
12
  import { buildAgentSession } from "../session-factory.js";
13
13
  import { apiKeyEnvVar, maybeRunOnboarding } from "../onboard.js";
14
+ import { resetLspServices } from "../../lsp/index.js";
14
15
  export function runCommand() {
15
16
  return new Command("run")
16
17
  .description("run a task once, or start an interactive session")
@@ -92,7 +93,14 @@ export function runCommand() {
92
93
  });
93
94
  const session = buildAgentSession(config, apiKey, process.cwd(), Boolean(process.stdin.isTTY), planMode, renderer, checkpoints, sandbox, hooksService.runner);
94
95
  if (interactive) {
95
- await runInteractive(session, undefined, renderer, checkpoints, hooksService.commands);
96
+ try {
97
+ await runInteractive(session, undefined, renderer, checkpoints, hooksService.commands);
98
+ }
99
+ finally {
100
+ // LSP (C.12): gracefully shut down any language servers spawned
101
+ // during the session (the process-exit kill-tree is the fail-safe).
102
+ await resetLspServices();
103
+ }
96
104
  return;
97
105
  }
98
106
  checkpoints?.beginRun(prompt);
@@ -110,6 +118,9 @@ export function runCommand() {
110
118
  }
111
119
  finally {
112
120
  renderer.close();
121
+ // LSP (C.12): gracefully shut down any language servers spawned during
122
+ // the run (the process-exit kill-tree is the fail-safe for a hard kill).
123
+ await resetLspServices();
113
124
  }
114
125
  // End-of-run usage summary (C.22): honest tokens + per-tier breakdown +
115
126
  // cost (only when priced). Printed after the live region is torn down.
@@ -9,6 +9,7 @@ import { Session, } from "../agent/index.js";
9
9
  import { PlanExecutionPolicy, runPlanSession } from "../plan/index.js";
10
10
  import { routerForConfig } from "../routing/index.js";
11
11
  import { MemoryService, rememberTool } from "../memory/index.js";
12
+ import { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "../lsp/index.js";
12
13
  import { appendRun } from "../usage/index.js";
13
14
  import { SubagentOrchestrator, makeSpawnSubagentTool, } from "../subagent/index.js";
14
15
  /**
@@ -136,6 +137,17 @@ export function buildAgentSession(config, apiKey, cwd, ttyInteractive, planMode
136
137
  logger.warn(`memory: excluded ${e.scope} entry ${e.id} — ${e.message}`);
137
138
  }
138
139
  }
140
+ // Per-language LSP (C.12): register the four read-only introspection tools
141
+ // only when enabled. They spawn and manage EXTERNAL language-server processes,
142
+ // so — like the sandbox — the feature is opt-in; when off, none is registered
143
+ // and no server ever spawns. Read-only (no approval), so they bypass the U.3
144
+ // gate like search_codebase and are available to subagents and plan proposals.
145
+ if (config.lsp.enabled) {
146
+ execRegistry.register(findDefinitionTool);
147
+ execRegistry.register(findReferencesTool);
148
+ execRegistry.register(getDiagnosticsTool);
149
+ execRegistry.register(hoverTool);
150
+ }
139
151
  // One io shared by every prompt in the session (plan approval, the U.3 gate,
140
152
  // and any gate inside a subagent), so they all coordinate with the same live
141
153
  // region. The full wrapper stack around an ApprovalService is factored here
@@ -236,8 +236,8 @@ export declare const TestConfigSchema: z.ZodObject<{
236
236
  captureBytes: number;
237
237
  command?: string | undefined;
238
238
  }, {
239
- maxIterations?: number | undefined;
240
239
  command?: string | undefined;
240
+ maxIterations?: number | undefined;
241
241
  captureBytes?: number | undefined;
242
242
  }>;
243
243
  /**
@@ -264,9 +264,9 @@ export declare const SubagentConfigSchema: z.ZodObject<{
264
264
  maxIterations: number;
265
265
  timeoutMs?: number | undefined;
266
266
  }, {
267
+ timeoutMs?: number | undefined;
267
268
  maxTokens?: number | undefined;
268
269
  maxIterations?: number | undefined;
269
- timeoutMs?: number | undefined;
270
270
  }>>;
271
271
  }, "strict", z.ZodTypeAny, {
272
272
  maxDepth: number;
@@ -278,9 +278,9 @@ export declare const SubagentConfigSchema: z.ZodObject<{
278
278
  }, {
279
279
  maxDepth?: number | undefined;
280
280
  defaultBudget?: {
281
+ timeoutMs?: number | undefined;
281
282
  maxTokens?: number | undefined;
282
283
  maxIterations?: number | undefined;
283
- timeoutMs?: number | undefined;
284
284
  } | undefined;
285
285
  }>;
286
286
  /**
@@ -408,6 +408,59 @@ export declare const MemoryConfigSchema: z.ZodObject<{
408
408
  maxRecallTokens?: number | undefined;
409
409
  }>;
410
410
  export type MemoryConfig = z.infer<typeof MemoryConfigSchema>;
411
+ /**
412
+ * Per-language LSP integration (C.12): read-only, symbol-aware tools
413
+ * (find_definition, find_references, get_diagnostics, hover) backed by a
414
+ * managed pool of language servers. OFF by default — like the sandbox and
415
+ * hooks, it spawns and manages EXTERNAL server processes, a real execution
416
+ * surface the user opts into explicitly. When on, a missing server binary is
417
+ * an actionable, coded failure (CRUXY_E_LSP_SERVER_NOT_FOUND), never a silent
418
+ * empty result. Servers spawn lazily per language, are reused for the session,
419
+ * and are shut down cleanly (with a process-exit kill-tree backstop).
420
+ */
421
+ export declare const LspConfigSchema: z.ZodObject<{
422
+ /** Master switch. When false, the four LSP tools are not registered and no
423
+ * server ever spawns (the feature stays fully inert). */
424
+ enabled: z.ZodDefault<z.ZodBoolean>;
425
+ /**
426
+ * Per-language command overrides, keyed by language id (e.g. `typescript`,
427
+ * `python`, `go`, `rust`). The value is the full command line
428
+ * (`"gopls -remote=auto"`); it is split on whitespace. Anything omitted
429
+ * falls back to the built-in default table (see `lsp/registry.ts`).
430
+ */
431
+ servers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
432
+ /** Kill and fail (CRUXY_E_LSP_TIMEOUT) if the `initialize` handshake does
433
+ * not complete within this many ms. */
434
+ startupTimeout: z.ZodDefault<z.ZodNumber>;
435
+ /** Fail a single query (CRUXY_E_LSP_TIMEOUT) if the server does not respond
436
+ * within this many ms — the server is kept alive, only the request errors. */
437
+ requestTimeout: z.ZodDefault<z.ZodNumber>;
438
+ /** Maximum concurrently-running language servers; acquiring past the cap
439
+ * evicts the idle-oldest server first. */
440
+ maxServers: z.ZodDefault<z.ZodNumber>;
441
+ /** Shut a server down after it has been idle this many ms. */
442
+ idleTimeout: z.ZodDefault<z.ZodNumber>;
443
+ /** Cap on locations/diagnostics returned by a single tool call; the rest are
444
+ * summarized as an "N more" note (bounded output). */
445
+ maxResults: z.ZodDefault<z.ZodNumber>;
446
+ }, "strict", z.ZodTypeAny, {
447
+ startupTimeout: number;
448
+ requestTimeout: number;
449
+ enabled: boolean;
450
+ servers: Record<string, string>;
451
+ maxServers: number;
452
+ idleTimeout: number;
453
+ maxResults: number;
454
+ }, {
455
+ startupTimeout?: number | undefined;
456
+ requestTimeout?: number | undefined;
457
+ enabled?: boolean | undefined;
458
+ servers?: Record<string, string> | undefined;
459
+ maxServers?: number | undefined;
460
+ idleTimeout?: number | undefined;
461
+ maxResults?: number | undefined;
462
+ }>;
463
+ export type LspConfig = z.infer<typeof LspConfigSchema>;
411
464
  /** A per-tier price, in the user's own currency, PER MILLION TOKENS (C.22). */
412
465
  export declare const TierPriceSchema: z.ZodObject<{
413
466
  /** Price per 1,000,000 input tokens. */
@@ -547,12 +600,12 @@ export declare const McpServerSchema: z.ZodObject<{
547
600
  url: z.ZodOptional<z.ZodString>;
548
601
  }, "strict", z.ZodTypeAny, {
549
602
  command?: string | undefined;
550
- args?: string[] | undefined;
551
603
  url?: string | undefined;
604
+ args?: string[] | undefined;
552
605
  }, {
553
606
  command?: string | undefined;
554
- args?: string[] | undefined;
555
607
  url?: string | undefined;
608
+ args?: string[] | undefined;
556
609
  }>;
557
610
  export declare const CruxyConfigSchema: z.ZodObject<{
558
611
  model: z.ZodDefault<z.ZodObject<{
@@ -742,6 +795,48 @@ export declare const CruxyConfigSchema: z.ZodObject<{
742
795
  overlapLines?: number | undefined;
743
796
  } | undefined;
744
797
  }>>;
798
+ lsp: z.ZodDefault<z.ZodObject<{
799
+ /** Master switch. When false, the four LSP tools are not registered and no
800
+ * server ever spawns (the feature stays fully inert). */
801
+ enabled: z.ZodDefault<z.ZodBoolean>;
802
+ /**
803
+ * Per-language command overrides, keyed by language id (e.g. `typescript`,
804
+ * `python`, `go`, `rust`). The value is the full command line
805
+ * (`"gopls -remote=auto"`); it is split on whitespace. Anything omitted
806
+ * falls back to the built-in default table (see `lsp/registry.ts`).
807
+ */
808
+ servers: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
809
+ /** Kill and fail (CRUXY_E_LSP_TIMEOUT) if the `initialize` handshake does
810
+ * not complete within this many ms. */
811
+ startupTimeout: z.ZodDefault<z.ZodNumber>;
812
+ /** Fail a single query (CRUXY_E_LSP_TIMEOUT) if the server does not respond
813
+ * within this many ms — the server is kept alive, only the request errors. */
814
+ requestTimeout: z.ZodDefault<z.ZodNumber>;
815
+ /** Maximum concurrently-running language servers; acquiring past the cap
816
+ * evicts the idle-oldest server first. */
817
+ maxServers: z.ZodDefault<z.ZodNumber>;
818
+ /** Shut a server down after it has been idle this many ms. */
819
+ idleTimeout: z.ZodDefault<z.ZodNumber>;
820
+ /** Cap on locations/diagnostics returned by a single tool call; the rest are
821
+ * summarized as an "N more" note (bounded output). */
822
+ maxResults: z.ZodDefault<z.ZodNumber>;
823
+ }, "strict", z.ZodTypeAny, {
824
+ startupTimeout: number;
825
+ requestTimeout: number;
826
+ enabled: boolean;
827
+ servers: Record<string, string>;
828
+ maxServers: number;
829
+ idleTimeout: number;
830
+ maxResults: number;
831
+ }, {
832
+ startupTimeout?: number | undefined;
833
+ requestTimeout?: number | undefined;
834
+ enabled?: boolean | undefined;
835
+ servers?: Record<string, string> | undefined;
836
+ maxServers?: number | undefined;
837
+ idleTimeout?: number | undefined;
838
+ maxResults?: number | undefined;
839
+ }>>;
745
840
  checkpoint: z.ZodDefault<z.ZodObject<{
746
841
  /** Auto-checkpoint before a run's first mutation (and enable `cruxy rollback`). */
747
842
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -773,9 +868,9 @@ export declare const CruxyConfigSchema: z.ZodObject<{
773
868
  maxIterations: number;
774
869
  timeoutMs?: number | undefined;
775
870
  }, {
871
+ timeoutMs?: number | undefined;
776
872
  maxTokens?: number | undefined;
777
873
  maxIterations?: number | undefined;
778
- timeoutMs?: number | undefined;
779
874
  }>>;
780
875
  }, "strict", z.ZodTypeAny, {
781
876
  maxDepth: number;
@@ -787,9 +882,9 @@ export declare const CruxyConfigSchema: z.ZodObject<{
787
882
  }, {
788
883
  maxDepth?: number | undefined;
789
884
  defaultBudget?: {
885
+ timeoutMs?: number | undefined;
790
886
  maxTokens?: number | undefined;
791
887
  maxIterations?: number | undefined;
792
- timeoutMs?: number | undefined;
793
888
  } | undefined;
794
889
  }>>;
795
890
  test: z.ZodDefault<z.ZodObject<{
@@ -804,8 +899,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
804
899
  captureBytes: number;
805
900
  command?: string | undefined;
806
901
  }, {
807
- maxIterations?: number | undefined;
808
902
  command?: string | undefined;
903
+ maxIterations?: number | undefined;
809
904
  captureBytes?: number | undefined;
810
905
  }>>;
811
906
  sandbox: z.ZodDefault<z.ZodObject<{
@@ -1015,12 +1110,12 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1015
1110
  url: z.ZodOptional<z.ZodString>;
1016
1111
  }, "strict", z.ZodTypeAny, {
1017
1112
  command?: string | undefined;
1018
- args?: string[] | undefined;
1019
1113
  url?: string | undefined;
1114
+ args?: string[] | undefined;
1020
1115
  }, {
1021
1116
  command?: string | undefined;
1022
- args?: string[] | undefined;
1023
1117
  url?: string | undefined;
1118
+ args?: string[] | undefined;
1024
1119
  }>>>;
1025
1120
  logLevel: z.ZodDefault<z.ZodEnum<["debug", "info", "warn", "error", "silent"]>>;
1026
1121
  }, "strict", z.ZodTypeAny, {
@@ -1120,6 +1215,15 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1120
1215
  overlapLines: number;
1121
1216
  };
1122
1217
  };
1218
+ lsp: {
1219
+ startupTimeout: number;
1220
+ requestTimeout: number;
1221
+ enabled: boolean;
1222
+ servers: Record<string, string>;
1223
+ maxServers: number;
1224
+ idleTimeout: number;
1225
+ maxResults: number;
1226
+ };
1123
1227
  test: {
1124
1228
  maxIterations: number;
1125
1229
  captureBytes: number;
@@ -1135,8 +1239,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1135
1239
  };
1136
1240
  mcpServers: Record<string, {
1137
1241
  command?: string | undefined;
1138
- args?: string[] | undefined;
1139
1242
  url?: string | undefined;
1243
+ args?: string[] | undefined;
1140
1244
  }>;
1141
1245
  logLevel: "debug" | "info" | "warn" | "error" | "silent";
1142
1246
  }, {
@@ -1186,9 +1290,9 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1186
1290
  subagent?: {
1187
1291
  maxDepth?: number | undefined;
1188
1292
  defaultBudget?: {
1293
+ timeoutMs?: number | undefined;
1189
1294
  maxTokens?: number | undefined;
1190
1295
  maxIterations?: number | undefined;
1191
- timeoutMs?: number | undefined;
1192
1296
  } | undefined;
1193
1297
  } | undefined;
1194
1298
  model?: {
@@ -1236,9 +1340,18 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1236
1340
  overlapLines?: number | undefined;
1237
1341
  } | undefined;
1238
1342
  } | undefined;
1343
+ lsp?: {
1344
+ startupTimeout?: number | undefined;
1345
+ requestTimeout?: number | undefined;
1346
+ enabled?: boolean | undefined;
1347
+ servers?: Record<string, string> | undefined;
1348
+ maxServers?: number | undefined;
1349
+ idleTimeout?: number | undefined;
1350
+ maxResults?: number | undefined;
1351
+ } | undefined;
1239
1352
  test?: {
1240
- maxIterations?: number | undefined;
1241
1353
  command?: string | undefined;
1354
+ maxIterations?: number | undefined;
1242
1355
  captureBytes?: number | undefined;
1243
1356
  } | undefined;
1244
1357
  hooks?: {
@@ -1251,8 +1364,8 @@ export declare const CruxyConfigSchema: z.ZodObject<{
1251
1364
  } | undefined;
1252
1365
  mcpServers?: Record<string, {
1253
1366
  command?: string | undefined;
1254
- args?: string[] | undefined;
1255
1367
  url?: string | undefined;
1368
+ args?: string[] | undefined;
1256
1369
  }> | undefined;
1257
1370
  logLevel?: "debug" | "info" | "warn" | "error" | "silent" | undefined;
1258
1371
  }>;
@@ -290,6 +290,44 @@ export const MemoryConfigSchema = z
290
290
  maxRecallTokens: z.number().int().positive().default(1000),
291
291
  })
292
292
  .strict();
293
+ /**
294
+ * Per-language LSP integration (C.12): read-only, symbol-aware tools
295
+ * (find_definition, find_references, get_diagnostics, hover) backed by a
296
+ * managed pool of language servers. OFF by default — like the sandbox and
297
+ * hooks, it spawns and manages EXTERNAL server processes, a real execution
298
+ * surface the user opts into explicitly. When on, a missing server binary is
299
+ * an actionable, coded failure (CRUXY_E_LSP_SERVER_NOT_FOUND), never a silent
300
+ * empty result. Servers spawn lazily per language, are reused for the session,
301
+ * and are shut down cleanly (with a process-exit kill-tree backstop).
302
+ */
303
+ export const LspConfigSchema = z
304
+ .object({
305
+ /** Master switch. When false, the four LSP tools are not registered and no
306
+ * server ever spawns (the feature stays fully inert). */
307
+ enabled: z.boolean().default(false),
308
+ /**
309
+ * Per-language command overrides, keyed by language id (e.g. `typescript`,
310
+ * `python`, `go`, `rust`). The value is the full command line
311
+ * (`"gopls -remote=auto"`); it is split on whitespace. Anything omitted
312
+ * falls back to the built-in default table (see `lsp/registry.ts`).
313
+ */
314
+ servers: z.record(z.string(), z.string().min(1)).default({}),
315
+ /** Kill and fail (CRUXY_E_LSP_TIMEOUT) if the `initialize` handshake does
316
+ * not complete within this many ms. */
317
+ startupTimeout: z.number().int().positive().default(15000),
318
+ /** Fail a single query (CRUXY_E_LSP_TIMEOUT) if the server does not respond
319
+ * within this many ms — the server is kept alive, only the request errors. */
320
+ requestTimeout: z.number().int().positive().default(10000),
321
+ /** Maximum concurrently-running language servers; acquiring past the cap
322
+ * evicts the idle-oldest server first. */
323
+ maxServers: z.number().int().positive().default(4),
324
+ /** Shut a server down after it has been idle this many ms. */
325
+ idleTimeout: z.number().int().positive().default(300000),
326
+ /** Cap on locations/diagnostics returned by a single tool call; the rest are
327
+ * summarized as an "N more" note (bounded output). */
328
+ maxResults: z.number().int().positive().default(100),
329
+ })
330
+ .strict();
293
331
  /** A per-tier price, in the user's own currency, PER MILLION TOKENS (C.22). */
294
332
  export const TierPriceSchema = z
295
333
  .object({
@@ -346,6 +384,7 @@ export const CruxyConfigSchema = z
346
384
  context: ContextConfigSchema.default({}),
347
385
  approval: ApprovalConfigSchema.default({}),
348
386
  index: IndexConfigSchema.default({}),
387
+ lsp: LspConfigSchema.default({}),
349
388
  checkpoint: CheckpointConfigSchema.default({}),
350
389
  subagent: SubagentConfigSchema.default({}),
351
390
  test: TestConfigSchema.default({}),
@@ -157,6 +157,29 @@ export declare function memoryInvalid(detail: string): CruxyError;
157
157
  * is your own local usage history, so deleting it loses nothing but history.
158
158
  */
159
159
  export declare function usageRead(path: string, reason: string, underlying?: unknown): CruxyError;
160
+ /**
161
+ * No language server is available for a query: either no server is configured
162
+ * for the file's language, or the configured/default binary is not installed.
163
+ * THE CORE HONESTY RULE for C.12: this is a coded, actionable failure — it must
164
+ * NEVER collapse into an empty result, because "no server" would then read as
165
+ * "no references found". `installHint` carries the concrete next step.
166
+ */
167
+ export declare function lspServerNotFound(language: string, reason: "no-spec" | "binary-missing", detail?: {
168
+ command?: string;
169
+ installHint?: string;
170
+ }): CruxyError;
171
+ /**
172
+ * A language server's `initialize` handshake or a single request exceeded its
173
+ * timeout. The process is killed (startup) or the request rejected (per-request)
174
+ * — cruxy never hangs waiting on an unresponsive server.
175
+ */
176
+ export declare function lspTimeout(language: string, phase: "startup" | "request", timeoutMs: number): CruxyError;
177
+ /**
178
+ * A language server crashed (its process exited unexpectedly) and could not be
179
+ * recovered — the single automatic restart also died. Distinct from a server
180
+ * that answered with zero results (that is an ordinary, non-error outcome).
181
+ */
182
+ export declare function lspCrashed(language: string, detail?: string): CruxyError;
160
183
  export declare function internal(underlying?: unknown): CruxyError;
161
184
  /**
162
185
  * Map a known provider/transport error (from `@cruxy/sdk`) to a typed
@@ -666,6 +666,73 @@ export function usageRead(path, reason, underlying) {
666
666
  meta: { path },
667
667
  });
668
668
  }
669
+ // ── per-language LSP (exit 15) — C.12 ─────────────────────────────────────────
670
+ /**
671
+ * No language server is available for a query: either no server is configured
672
+ * for the file's language, or the configured/default binary is not installed.
673
+ * THE CORE HONESTY RULE for C.12: this is a coded, actionable failure — it must
674
+ * NEVER collapse into an empty result, because "no server" would then read as
675
+ * "no references found". `installHint` carries the concrete next step.
676
+ */
677
+ export function lspServerNotFound(language, reason, detail) {
678
+ const noSpec = reason === "no-spec";
679
+ return new CruxyError({
680
+ code: ErrorCode.LspServerNotFound,
681
+ title: noSpec
682
+ ? `no language server configured for "${language}"`
683
+ : `the "${language}" language server (${detail?.command ?? "?"}) is not installed`,
684
+ cause: noSpec
685
+ ? "this file's language has no entry in the default server table or `lsp.servers`"
686
+ : `\`${detail?.command ?? language}\` was not found on PATH`,
687
+ nextSteps: noSpec
688
+ ? [
689
+ `set a server command, e.g. \`cruxy config set lsp.servers.${language} "<command> --stdio"\``,
690
+ ]
691
+ : [
692
+ detail?.installHint ?? `install the ${language} language server`,
693
+ `or point cruxy at an installed one: \`cruxy config set lsp.servers.${language} "<command>"\``,
694
+ ],
695
+ meta: { language, reason, command: detail?.command },
696
+ });
697
+ }
698
+ /**
699
+ * A language server's `initialize` handshake or a single request exceeded its
700
+ * timeout. The process is killed (startup) or the request rejected (per-request)
701
+ * — cruxy never hangs waiting on an unresponsive server.
702
+ */
703
+ export function lspTimeout(language, phase, timeoutMs) {
704
+ return new CruxyError({
705
+ code: ErrorCode.LspTimeout,
706
+ title: phase === "startup"
707
+ ? `the "${language}" language server did not start within ${timeoutMs}ms`
708
+ : `the "${language}" language server did not respond within ${timeoutMs}ms`,
709
+ cause: phase === "startup"
710
+ ? "the initialize handshake timed out; the process was killed"
711
+ : "the request timed out; the server was left running for later calls",
712
+ nextSteps: [
713
+ `raise the bound if the server is just slow to start: \`cruxy config set lsp.${phase === "startup" ? "startupTimeout" : "requestTimeout"} ${timeoutMs * 2}\``,
714
+ "re-run with --verbose to see the server's stderr",
715
+ ],
716
+ meta: { language, phase, timeoutMs },
717
+ });
718
+ }
719
+ /**
720
+ * A language server crashed (its process exited unexpectedly) and could not be
721
+ * recovered — the single automatic restart also died. Distinct from a server
722
+ * that answered with zero results (that is an ordinary, non-error outcome).
723
+ */
724
+ export function lspCrashed(language, detail) {
725
+ return new CruxyError({
726
+ code: ErrorCode.LspCrashed,
727
+ title: `the "${language}" language server crashed`,
728
+ cause: detail ?? "the server process exited unexpectedly and did not recover",
729
+ nextSteps: [
730
+ "re-run with --verbose to see the server's stderr",
731
+ `verify the server runs standalone (the command in \`lsp.servers.${language}\` or the default)`,
732
+ ],
733
+ meta: { language },
734
+ });
735
+ }
669
736
  // ── internal (exit 1) ─────────────────────────────────────────────────────────
670
737
  export function internal(underlying) {
671
738
  return new CruxyError({
@@ -83,6 +83,16 @@ export declare const ErrorCode: {
83
83
  /** The local usage store is corrupt/unreadable — the read is SKIPPED and this
84
84
  * is surfaced; never fatal to a run (usage display is best-effort). */
85
85
  readonly UsageRead: "CRUXY_E_USAGE_READ";
86
+ /** A language server binary is not installed / not on PATH (or no server is
87
+ * configured for the file's language). Actionable, NEVER a silent empty
88
+ * result — "no server" must not read as "no references found". */
89
+ readonly LspServerNotFound: "CRUXY_E_LSP_SERVER_NOT_FOUND";
90
+ /** A server's `initialize` handshake or a single request exceeded its timeout
91
+ * — the process was killed / the request errored, never left to hang. */
92
+ readonly LspTimeout: "CRUXY_E_LSP_TIMEOUT";
93
+ /** A language server crashed (and, where applicable, a single restart also
94
+ * failed). Distinct from "server returned no results". */
95
+ readonly LspCrashed: "CRUXY_E_LSP_CRASHED";
86
96
  };
87
97
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
88
98
  /** The process exit code for an error code (defaults to 1 for safety). */
@@ -99,6 +99,17 @@ export const ErrorCode = {
99
99
  /** The local usage store is corrupt/unreadable — the read is SKIPPED and this
100
100
  * is surfaced; never fatal to a run (usage display is best-effort). */
101
101
  UsageRead: "CRUXY_E_USAGE_READ",
102
+ // per-language LSP (exit 15) — C.12
103
+ /** A language server binary is not installed / not on PATH (or no server is
104
+ * configured for the file's language). Actionable, NEVER a silent empty
105
+ * result — "no server" must not read as "no references found". */
106
+ LspServerNotFound: "CRUXY_E_LSP_SERVER_NOT_FOUND",
107
+ /** A server's `initialize` handshake or a single request exceeded its timeout
108
+ * — the process was killed / the request errored, never left to hang. */
109
+ LspTimeout: "CRUXY_E_LSP_TIMEOUT",
110
+ /** A language server crashed (and, where applicable, a single restart also
111
+ * failed). Distinct from "server returned no results". */
112
+ LspCrashed: "CRUXY_E_LSP_CRASHED",
102
113
  };
103
114
  /**
104
115
  * Category exit codes. Distinct per category so a caller (CI, a script) can
@@ -170,6 +181,12 @@ const EXIT_CODES = {
170
181
  // fix (delete the file); it shares the usage exit code and is never fatal to a
171
182
  // run — the aggregation just skips it.
172
183
  [ErrorCode.UsageRead]: 2,
184
+ // Per-language LSP (C.12). A missing server / timeout / crash surfaces inside
185
+ // a tool result (the agent reads and reroutes) and only exits the process if
186
+ // thrown directly; grouped for a greppable exit code.
187
+ [ErrorCode.LspServerNotFound]: 15,
188
+ [ErrorCode.LspTimeout]: 15,
189
+ [ErrorCode.LspCrashed]: 15,
173
190
  };
174
191
  /** The process exit code for an error code (defaults to 1 for safety). */
175
192
  export function exitCodeFor(code) {
@@ -0,0 +1,25 @@
1
+ import type { LspPool } from "./pool.js";
2
+ import type { LspDiagnostic, LspHover, LspLocation } from "./types.js";
3
+ /**
4
+ * Typed, normalized LSP queries over a {@link LspPool} (C.12). Its only real
5
+ * job beyond delegating is the availability discipline: it resolves a file's
6
+ * language and acquires a server, letting the pool's coded errors (no server /
7
+ * timeout / crash) propagate — while a server's genuine `[]`/`null` answer flows
8
+ * back untouched. That keeps "couldn't ask" and "asked, nothing found" distinct
9
+ * all the way up to the tool boundary. Callers pass ABSOLUTE, in-root paths
10
+ * (validated by the tool via `resolveInRoot`); the server relativizes results.
11
+ */
12
+ export declare class LspClient {
13
+ private readonly pool;
14
+ constructor(pool: LspPool);
15
+ definition(file: string, line: number, col: number): Promise<LspLocation[]>;
16
+ references(file: string, line: number, col: number): Promise<LspLocation[]>;
17
+ hover(file: string, line: number, col: number): Promise<LspHover | null>;
18
+ diagnostics(file: string): Promise<LspDiagnostic[]>;
19
+ /**
20
+ * Resolve the file's language and hand back a live server. An unmapped
21
+ * extension is a coded, actionable failure (`no-spec`) — NOT an empty result,
22
+ * so "unknown language" can never read as "no references found".
23
+ */
24
+ private serverFor;
25
+ }
@@ -0,0 +1,43 @@
1
+ import path from "node:path";
2
+ import { lspServerNotFound } from "../errors/index.js";
3
+ import { languageForFile } from "./registry.js";
4
+ /**
5
+ * Typed, normalized LSP queries over a {@link LspPool} (C.12). Its only real
6
+ * job beyond delegating is the availability discipline: it resolves a file's
7
+ * language and acquires a server, letting the pool's coded errors (no server /
8
+ * timeout / crash) propagate — while a server's genuine `[]`/`null` answer flows
9
+ * back untouched. That keeps "couldn't ask" and "asked, nothing found" distinct
10
+ * all the way up to the tool boundary. Callers pass ABSOLUTE, in-root paths
11
+ * (validated by the tool via `resolveInRoot`); the server relativizes results.
12
+ */
13
+ export class LspClient {
14
+ pool;
15
+ constructor(pool) {
16
+ this.pool = pool;
17
+ }
18
+ async definition(file, line, col) {
19
+ return (await this.serverFor(file)).definition(file, line, col);
20
+ }
21
+ async references(file, line, col) {
22
+ return (await this.serverFor(file)).references(file, line, col);
23
+ }
24
+ async hover(file, line, col) {
25
+ return (await this.serverFor(file)).hover(file, line, col);
26
+ }
27
+ async diagnostics(file) {
28
+ return (await this.serverFor(file)).diagnostics(file);
29
+ }
30
+ /**
31
+ * Resolve the file's language and hand back a live server. An unmapped
32
+ * extension is a coded, actionable failure (`no-spec`) — NOT an empty result,
33
+ * so "unknown language" can never read as "no references found".
34
+ */
35
+ serverFor(file) {
36
+ const language = languageForFile(file);
37
+ if (!language) {
38
+ const ext = path.extname(file) || path.basename(file);
39
+ throw lspServerNotFound(ext, "no-spec");
40
+ }
41
+ return this.pool.acquire(language);
42
+ }
43
+ }
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export { DEFAULT_SPECS, EXT_TO_LANGUAGE, LspRegistry, binaryOnPath, languageForFile, } from "./registry.js";
3
+ export { StdioTransport, TransportTimeoutError, killTree, } from "./transport.js";
4
+ export { Server, type ServerTimeouts } from "./server.js";
5
+ export { LspPool, type PoolOptions, type PoolDeps } from "./pool.js";
6
+ export { LspClient } from "./client.js";
7
+ export { getLspService, resetLspServices, type LspService, type LspServiceDeps, } from "./service.js";
8
+ export { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "./tools/index.js";
@@ -0,0 +1,8 @@
1
+ export * from "./types.js";
2
+ export { DEFAULT_SPECS, EXT_TO_LANGUAGE, LspRegistry, binaryOnPath, languageForFile, } from "./registry.js";
3
+ export { StdioTransport, TransportTimeoutError, killTree, } from "./transport.js";
4
+ export { Server } from "./server.js";
5
+ export { LspPool } from "./pool.js";
6
+ export { LspClient } from "./client.js";
7
+ export { getLspService, resetLspServices, } from "./service.js";
8
+ export { findDefinitionTool, findReferencesTool, getDiagnosticsTool, hoverTool, } from "./tools/index.js";