@cruxy/cli 0.18.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.
- package/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +20 -0
- package/dist/agent/session.d.ts +18 -1
- package/dist/agent/session.js +38 -6
- package/dist/cli/commands/run.js +31 -1
- package/dist/cli/commands/usage.d.ts +9 -0
- package/dist/cli/commands/usage.js +81 -0
- package/dist/cli/program.js +2 -0
- package/dist/cli/session-factory.js +30 -1
- package/dist/config/schema.d.ts +407 -14
- package/dist/config/schema.js +77 -0
- package/dist/constants.d.ts +7 -0
- package/dist/constants.js +7 -0
- package/dist/errors/constructors.d.ts +30 -0
- package/dist/errors/constructors.js +86 -0
- package/dist/errors/types.d.ts +13 -0
- package/dist/errors/types.js +25 -0
- package/dist/lsp/client.d.ts +25 -0
- package/dist/lsp/client.js +43 -0
- package/dist/lsp/index.d.ts +8 -0
- package/dist/lsp/index.js +8 -0
- package/dist/lsp/pool.d.ts +48 -0
- package/dist/lsp/pool.js +132 -0
- package/dist/lsp/registry.d.ts +38 -0
- package/dist/lsp/registry.js +133 -0
- package/dist/lsp/server.d.ts +48 -0
- package/dist/lsp/server.js +264 -0
- package/dist/lsp/service.d.ts +44 -0
- package/dist/lsp/service.js +76 -0
- package/dist/lsp/tools/common.d.ts +23 -0
- package/dist/lsp/tools/common.js +75 -0
- package/dist/lsp/tools/find-definition.d.ts +23 -0
- package/dist/lsp/tools/find-definition.js +41 -0
- package/dist/lsp/tools/find-references.d.ts +23 -0
- package/dist/lsp/tools/find-references.js +41 -0
- package/dist/lsp/tools/get-diagnostics.d.ts +17 -0
- package/dist/lsp/tools/get-diagnostics.js +43 -0
- package/dist/lsp/tools/hover.d.ts +23 -0
- package/dist/lsp/tools/hover.js +38 -0
- package/dist/lsp/tools/index.d.ts +4 -0
- package/dist/lsp/tools/index.js +4 -0
- package/dist/lsp/transport.d.ts +48 -0
- package/dist/lsp/transport.js +264 -0
- package/dist/lsp/types.d.ts +107 -0
- package/dist/lsp/types.js +1 -0
- package/dist/plan/service.d.ts +10 -1
- package/dist/plan/service.js +2 -0
- package/dist/tools/file/grep-files.d.ts +2 -2
- package/dist/usage/collect.d.ts +40 -0
- package/dist/usage/collect.js +34 -0
- package/dist/usage/cost.d.ts +19 -0
- package/dist/usage/cost.js +29 -0
- package/dist/usage/index.d.ts +15 -0
- package/dist/usage/index.js +15 -0
- package/dist/usage/store.d.ts +37 -0
- package/dist/usage/store.js +83 -0
- package/dist/usage/summary.d.ts +32 -0
- package/dist/usage/summary.js +119 -0
- package/dist/usage/types.d.ts +220 -0
- package/dist/usage/types.js +47 -0
- package/package.json +1 -1
|
@@ -150,6 +150,36 @@ export declare function memorySecretRefused(kind: string): CruxyError;
|
|
|
150
150
|
export declare function memoryUntrusted(root: string): CruxyError;
|
|
151
151
|
/** A malformed memory entry was rejected (C.29) — excluded, never eval'd. */
|
|
152
152
|
export declare function memoryInvalid(detail: string): CruxyError;
|
|
153
|
+
/**
|
|
154
|
+
* The local usage store (`~/.cruxy/usage/runs.json`) is corrupt or unreadable
|
|
155
|
+
* (C.22). Never fatal: usage accounting is best-effort, so the read is SKIPPED
|
|
156
|
+
* and this is surfaced. The fix is always to reset the file — its only content
|
|
157
|
+
* is your own local usage history, so deleting it loses nothing but history.
|
|
158
|
+
*/
|
|
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;
|
|
153
183
|
export declare function internal(underlying?: unknown): CruxyError;
|
|
154
184
|
/**
|
|
155
185
|
* Map a known provider/transport error (from `@cruxy/sdk`) to a typed
|
|
@@ -647,6 +647,92 @@ export function memoryInvalid(detail) {
|
|
|
647
647
|
],
|
|
648
648
|
});
|
|
649
649
|
}
|
|
650
|
+
// ── usage telemetry (exit 2) — C.22 ───────────────────────────────────────────
|
|
651
|
+
/**
|
|
652
|
+
* The local usage store (`~/.cruxy/usage/runs.json`) is corrupt or unreadable
|
|
653
|
+
* (C.22). Never fatal: usage accounting is best-effort, so the read is SKIPPED
|
|
654
|
+
* and this is surfaced. The fix is always to reset the file — its only content
|
|
655
|
+
* is your own local usage history, so deleting it loses nothing but history.
|
|
656
|
+
*/
|
|
657
|
+
export function usageRead(path, reason, underlying) {
|
|
658
|
+
return new CruxyError({
|
|
659
|
+
code: ErrorCode.UsageRead,
|
|
660
|
+
title: "could not read the local usage store",
|
|
661
|
+
cause: reason,
|
|
662
|
+
nextSteps: [
|
|
663
|
+
`delete ${path} to reset it (it holds only your local usage history)`,
|
|
664
|
+
],
|
|
665
|
+
underlying,
|
|
666
|
+
meta: { path },
|
|
667
|
+
});
|
|
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
|
+
}
|
|
650
736
|
// ── internal (exit 1) ─────────────────────────────────────────────────────────
|
|
651
737
|
export function internal(underlying) {
|
|
652
738
|
return new CruxyError({
|
package/dist/errors/types.d.ts
CHANGED
|
@@ -80,6 +80,19 @@ export declare const ErrorCode: {
|
|
|
80
80
|
/** A memory write was refused because the content matched a secret shape —
|
|
81
81
|
* secrets are never persisted (defense in depth over C.17). */
|
|
82
82
|
readonly MemorySecret: "CRUXY_E_MEMORY_SECRET";
|
|
83
|
+
/** The local usage store is corrupt/unreadable — the read is SKIPPED and this
|
|
84
|
+
* is surfaced; never fatal to a run (usage display is best-effort). */
|
|
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";
|
|
83
96
|
};
|
|
84
97
|
export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
|
|
85
98
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
package/dist/errors/types.js
CHANGED
|
@@ -95,6 +95,21 @@ export const ErrorCode = {
|
|
|
95
95
|
/** A memory write was refused because the content matched a secret shape —
|
|
96
96
|
* secrets are never persisted (defense in depth over C.17). */
|
|
97
97
|
MemorySecret: "CRUXY_E_MEMORY_SECRET",
|
|
98
|
+
// usage telemetry (exit 2) — C.22
|
|
99
|
+
/** The local usage store is corrupt/unreadable — the read is SKIPPED and this
|
|
100
|
+
* is surfaced; never fatal to a run (usage display is best-effort). */
|
|
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",
|
|
98
113
|
};
|
|
99
114
|
/**
|
|
100
115
|
* Category exit codes. Distinct per category so a caller (CI, a script) can
|
|
@@ -162,6 +177,16 @@ const EXIT_CODES = {
|
|
|
162
177
|
[ErrorCode.MemoryInvalid]: 14,
|
|
163
178
|
[ErrorCode.MemoryUntrusted]: 14,
|
|
164
179
|
[ErrorCode.MemorySecret]: 14,
|
|
180
|
+
// Usage telemetry (C.22). A corrupt store is a usage/data problem the user can
|
|
181
|
+
// fix (delete the file); it shares the usage exit code and is never fatal to a
|
|
182
|
+
// run — the aggregation just skips it.
|
|
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,
|
|
165
190
|
};
|
|
166
191
|
/** The process exit code for an error code (defaults to 1 for safety). */
|
|
167
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";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type ServerTimeouts } from "./server.js";
|
|
2
|
+
import { LspRegistry } from "./registry.js";
|
|
3
|
+
import type { LanguageServer, ServerSpec } from "./types.js";
|
|
4
|
+
/** Pool bounds + timeouts, threaded from `lsp.*` config. */
|
|
5
|
+
export interface PoolOptions extends ServerTimeouts {
|
|
6
|
+
maxServers: number;
|
|
7
|
+
idleTimeout: number;
|
|
8
|
+
}
|
|
9
|
+
/** Test seams: substitute the spawn+handshake and the clock. */
|
|
10
|
+
export interface PoolDeps {
|
|
11
|
+
/** Build+initialize a server for a spec. Defaults to a real stdio process. */
|
|
12
|
+
startServer?: (spec: ServerSpec) => Promise<LanguageServer>;
|
|
13
|
+
/** Monotonic-ish clock for idle accounting; defaults to `Date.now`. */
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A per-project pool of language servers (C.12): one server per language,
|
|
18
|
+
* spawned lazily on first use, reused across calls, evicted when idle or when
|
|
19
|
+
* the `maxServers` cap forces it, restarted ONCE on crash then surfaced as a
|
|
20
|
+
* coded error, and all shut down cleanly on teardown. Unavailability (no spec /
|
|
21
|
+
* missing binary) throws an actionable error — never a silent empty result.
|
|
22
|
+
*/
|
|
23
|
+
export declare class LspPool {
|
|
24
|
+
private readonly registry;
|
|
25
|
+
private readonly root;
|
|
26
|
+
private readonly options;
|
|
27
|
+
private readonly entries;
|
|
28
|
+
private readonly starting;
|
|
29
|
+
private readonly now;
|
|
30
|
+
private readonly startServer;
|
|
31
|
+
constructor(registry: LspRegistry, root: string, options: PoolOptions, deps?: PoolDeps);
|
|
32
|
+
/**
|
|
33
|
+
* Get a ready server for `language`, spawning lazily and reusing across calls.
|
|
34
|
+
* Throws `CRUXY_E_LSP_SERVER_NOT_FOUND` when unavailable, `CRUXY_E_LSP_TIMEOUT`
|
|
35
|
+
* on a startup timeout, or `CRUXY_E_LSP_CRASHED` when a crashed server cannot
|
|
36
|
+
* be recovered by its single automatic restart.
|
|
37
|
+
*/
|
|
38
|
+
acquire(language: string): Promise<LanguageServer>;
|
|
39
|
+
/** Shut down every server idle beyond `idleTimeout` (called on each acquire). */
|
|
40
|
+
sweepIdle(): void;
|
|
41
|
+
/** Cleanly shut down all servers (session end / process teardown). */
|
|
42
|
+
shutdownAll(force?: boolean): Promise<void>;
|
|
43
|
+
/** Live server count — for tests asserting reuse and the maxServers bound. */
|
|
44
|
+
size(): number;
|
|
45
|
+
private startAndStore;
|
|
46
|
+
/** Evict least-recently-used servers until there is room under the cap. */
|
|
47
|
+
private evictToCap;
|
|
48
|
+
}
|
package/dist/lsp/pool.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { lspCrashed, lspServerNotFound } from "../errors/index.js";
|
|
2
|
+
import { StdioTransport } from "./transport.js";
|
|
3
|
+
import { Server } from "./server.js";
|
|
4
|
+
/**
|
|
5
|
+
* A per-project pool of language servers (C.12): one server per language,
|
|
6
|
+
* spawned lazily on first use, reused across calls, evicted when idle or when
|
|
7
|
+
* the `maxServers` cap forces it, restarted ONCE on crash then surfaced as a
|
|
8
|
+
* coded error, and all shut down cleanly on teardown. Unavailability (no spec /
|
|
9
|
+
* missing binary) throws an actionable error — never a silent empty result.
|
|
10
|
+
*/
|
|
11
|
+
export class LspPool {
|
|
12
|
+
registry;
|
|
13
|
+
root;
|
|
14
|
+
options;
|
|
15
|
+
entries = new Map();
|
|
16
|
+
starting = new Map();
|
|
17
|
+
now;
|
|
18
|
+
startServer;
|
|
19
|
+
constructor(registry, root, options, deps = {}) {
|
|
20
|
+
this.registry = registry;
|
|
21
|
+
this.root = root;
|
|
22
|
+
this.options = options;
|
|
23
|
+
this.now = deps.now ?? (() => Date.now());
|
|
24
|
+
this.startServer =
|
|
25
|
+
deps.startServer ??
|
|
26
|
+
((spec) => Server.start(new StdioTransport(spec, this.root), this.root, spec, {
|
|
27
|
+
startupTimeout: options.startupTimeout,
|
|
28
|
+
requestTimeout: options.requestTimeout,
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Get a ready server for `language`, spawning lazily and reusing across calls.
|
|
33
|
+
* Throws `CRUXY_E_LSP_SERVER_NOT_FOUND` when unavailable, `CRUXY_E_LSP_TIMEOUT`
|
|
34
|
+
* on a startup timeout, or `CRUXY_E_LSP_CRASHED` when a crashed server cannot
|
|
35
|
+
* be recovered by its single automatic restart.
|
|
36
|
+
*/
|
|
37
|
+
async acquire(language) {
|
|
38
|
+
const resolution = this.registry.resolve(language);
|
|
39
|
+
if (!resolution.available) {
|
|
40
|
+
throw lspServerNotFound(language, resolution.reason, {
|
|
41
|
+
command: resolution.spec?.command,
|
|
42
|
+
installHint: resolution.spec?.installHint,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
this.sweepIdle();
|
|
46
|
+
const existing = this.entries.get(language);
|
|
47
|
+
if (existing) {
|
|
48
|
+
if (existing.server.alive) {
|
|
49
|
+
existing.lastUsed = this.now();
|
|
50
|
+
existing.restarts = 0; // survived a live call — the crash budget resets
|
|
51
|
+
return existing.server;
|
|
52
|
+
}
|
|
53
|
+
// The server crashed. Allow exactly one automatic restart.
|
|
54
|
+
this.entries.delete(language);
|
|
55
|
+
await safeShutdown(existing.server, true);
|
|
56
|
+
if (existing.restarts >= 1) {
|
|
57
|
+
throw lspCrashed(language);
|
|
58
|
+
}
|
|
59
|
+
return this.startAndStore(resolution.spec, existing.restarts + 1);
|
|
60
|
+
}
|
|
61
|
+
return this.startAndStore(resolution.spec, 0);
|
|
62
|
+
}
|
|
63
|
+
/** Shut down every server idle beyond `idleTimeout` (called on each acquire). */
|
|
64
|
+
sweepIdle() {
|
|
65
|
+
const cutoff = this.now() - this.options.idleTimeout;
|
|
66
|
+
for (const [language, entry] of this.entries) {
|
|
67
|
+
if (entry.lastUsed <= cutoff) {
|
|
68
|
+
this.entries.delete(language);
|
|
69
|
+
void safeShutdown(entry.server, false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Cleanly shut down all servers (session end / process teardown). */
|
|
74
|
+
async shutdownAll(force = false) {
|
|
75
|
+
const servers = [...this.entries.values()].map((e) => e.server);
|
|
76
|
+
this.entries.clear();
|
|
77
|
+
this.starting.clear();
|
|
78
|
+
await Promise.all(servers.map((s) => safeShutdown(s, force)));
|
|
79
|
+
}
|
|
80
|
+
/** Live server count — for tests asserting reuse and the maxServers bound. */
|
|
81
|
+
size() {
|
|
82
|
+
return this.entries.size;
|
|
83
|
+
}
|
|
84
|
+
async startAndStore(spec, restarts) {
|
|
85
|
+
const language = spec.language;
|
|
86
|
+
// Coalesce concurrent first-use so a language never double-spawns.
|
|
87
|
+
const inFlight = this.starting.get(language);
|
|
88
|
+
if (inFlight)
|
|
89
|
+
return inFlight;
|
|
90
|
+
const pending = (async () => {
|
|
91
|
+
this.evictToCap();
|
|
92
|
+
const server = await this.startServer(spec); // may throw lspTimeout
|
|
93
|
+
this.entries.set(language, { server, lastUsed: this.now(), restarts });
|
|
94
|
+
return server;
|
|
95
|
+
})();
|
|
96
|
+
this.starting.set(language, pending);
|
|
97
|
+
try {
|
|
98
|
+
return await pending;
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
this.starting.delete(language);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Evict least-recently-used servers until there is room under the cap. */
|
|
105
|
+
evictToCap() {
|
|
106
|
+
while (this.entries.size >= this.options.maxServers &&
|
|
107
|
+
this.entries.size > 0) {
|
|
108
|
+
let oldestLang = null;
|
|
109
|
+
let oldest = Infinity;
|
|
110
|
+
for (const [language, entry] of this.entries) {
|
|
111
|
+
if (entry.lastUsed < oldest) {
|
|
112
|
+
oldest = entry.lastUsed;
|
|
113
|
+
oldestLang = language;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (oldestLang === null)
|
|
117
|
+
return;
|
|
118
|
+
const evicted = this.entries.get(oldestLang);
|
|
119
|
+
this.entries.delete(oldestLang);
|
|
120
|
+
void safeShutdown(evicted.server, false);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Shut a server down without letting a teardown error escape. */
|
|
125
|
+
async function safeShutdown(server, force) {
|
|
126
|
+
try {
|
|
127
|
+
await server.shutdown(force);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* best-effort — the process is being torn down regardless */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { BinaryPresent, LspConfig, ServerResolution, ServerSpec } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The default language → server table (C.12). Documented and overridable per
|
|
4
|
+
* language via `lsp.servers.<lang>`. cruxy never guesses a command: a language
|
|
5
|
+
* is served only if it appears here or in config, and the binary must actually
|
|
6
|
+
* be installed — otherwise the caller gets a coded, actionable failure.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DEFAULT_SPECS: Record<string, Omit<ServerSpec, "language">>;
|
|
9
|
+
/**
|
|
10
|
+
* File extension → language id. Extensions are matched case-insensitively and
|
|
11
|
+
* include the leading dot. A file whose extension is absent here has no server
|
|
12
|
+
* (the caller reports `no-spec`, distinct from a missing binary).
|
|
13
|
+
*/
|
|
14
|
+
export declare const EXT_TO_LANGUAGE: Record<string, string>;
|
|
15
|
+
/** The language id for a file path, or `null` when its extension is unmapped. */
|
|
16
|
+
export declare function languageForFile(filePath: string): string | null;
|
|
17
|
+
/**
|
|
18
|
+
* Default binary-presence check: resolves `command` the way a shell would.
|
|
19
|
+
* An absolute/relative path is tested directly; a bare name is searched across
|
|
20
|
+
* `PATH` (with `PATHEXT` on win32). Pure filesystem probing — never spawns.
|
|
21
|
+
* Injectable via {@link LspRegistry} so tests can force present/absent.
|
|
22
|
+
*/
|
|
23
|
+
export declare const binaryOnPath: BinaryPresent;
|
|
24
|
+
/**
|
|
25
|
+
* Resolves a language to a launchable {@link ServerSpec}, or an explicit
|
|
26
|
+
* "not available" verdict. Config overrides (`lsp.servers.<lang>`, a full
|
|
27
|
+
* command line split on whitespace) win over the default table; the resolved
|
|
28
|
+
* binary must be present or the verdict is `binary-missing`.
|
|
29
|
+
*/
|
|
30
|
+
export declare class LspRegistry {
|
|
31
|
+
private readonly config;
|
|
32
|
+
private readonly present;
|
|
33
|
+
constructor(config: LspConfig, present?: BinaryPresent);
|
|
34
|
+
/** The server spec for a language, from config override or the default table. */
|
|
35
|
+
specFor(language: string): ServerSpec | null;
|
|
36
|
+
/** Explicit availability verdict — never conflates "no spec" with "no binary". */
|
|
37
|
+
resolve(language: string): ServerResolution;
|
|
38
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* The default language → server table (C.12). Documented and overridable per
|
|
5
|
+
* language via `lsp.servers.<lang>`. cruxy never guesses a command: a language
|
|
6
|
+
* is served only if it appears here or in config, and the binary must actually
|
|
7
|
+
* be installed — otherwise the caller gets a coded, actionable failure.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_SPECS = {
|
|
10
|
+
typescript: {
|
|
11
|
+
command: "typescript-language-server",
|
|
12
|
+
args: ["--stdio"],
|
|
13
|
+
installHint: "install it: `npm i -g typescript-language-server typescript`",
|
|
14
|
+
},
|
|
15
|
+
javascript: {
|
|
16
|
+
command: "typescript-language-server",
|
|
17
|
+
args: ["--stdio"],
|
|
18
|
+
installHint: "install it: `npm i -g typescript-language-server typescript`",
|
|
19
|
+
},
|
|
20
|
+
python: {
|
|
21
|
+
command: "pyright-langserver",
|
|
22
|
+
args: ["--stdio"],
|
|
23
|
+
installHint: "install it: `npm i -g pyright`",
|
|
24
|
+
},
|
|
25
|
+
go: {
|
|
26
|
+
command: "gopls",
|
|
27
|
+
args: [],
|
|
28
|
+
installHint: "install it: `go install golang.org/x/tools/gopls@latest`",
|
|
29
|
+
},
|
|
30
|
+
rust: {
|
|
31
|
+
command: "rust-analyzer",
|
|
32
|
+
args: [],
|
|
33
|
+
installHint: "install it: `rustup component add rust-analyzer`",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* File extension → language id. Extensions are matched case-insensitively and
|
|
38
|
+
* include the leading dot. A file whose extension is absent here has no server
|
|
39
|
+
* (the caller reports `no-spec`, distinct from a missing binary).
|
|
40
|
+
*/
|
|
41
|
+
export const EXT_TO_LANGUAGE = {
|
|
42
|
+
".ts": "typescript",
|
|
43
|
+
".tsx": "typescript",
|
|
44
|
+
".mts": "typescript",
|
|
45
|
+
".cts": "typescript",
|
|
46
|
+
".js": "javascript",
|
|
47
|
+
".jsx": "javascript",
|
|
48
|
+
".mjs": "javascript",
|
|
49
|
+
".cjs": "javascript",
|
|
50
|
+
".py": "python",
|
|
51
|
+
".pyi": "python",
|
|
52
|
+
".go": "go",
|
|
53
|
+
".rs": "rust",
|
|
54
|
+
};
|
|
55
|
+
/** The language id for a file path, or `null` when its extension is unmapped. */
|
|
56
|
+
export function languageForFile(filePath) {
|
|
57
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
58
|
+
return EXT_TO_LANGUAGE[ext] ?? null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Default binary-presence check: resolves `command` the way a shell would.
|
|
62
|
+
* An absolute/relative path is tested directly; a bare name is searched across
|
|
63
|
+
* `PATH` (with `PATHEXT` on win32). Pure filesystem probing — never spawns.
|
|
64
|
+
* Injectable via {@link LspRegistry} so tests can force present/absent.
|
|
65
|
+
*/
|
|
66
|
+
export const binaryOnPath = (command) => {
|
|
67
|
+
if (command.includes(path.sep) || command.includes("/")) {
|
|
68
|
+
return isExecutable(command);
|
|
69
|
+
}
|
|
70
|
+
const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
|
|
71
|
+
const exts = process.platform === "win32"
|
|
72
|
+
? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")
|
|
73
|
+
: [""];
|
|
74
|
+
for (const dir of dirs) {
|
|
75
|
+
for (const ext of exts) {
|
|
76
|
+
if (isExecutable(path.join(dir, command + ext)))
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
};
|
|
82
|
+
function isExecutable(candidate) {
|
|
83
|
+
try {
|
|
84
|
+
const stat = fs.statSync(candidate);
|
|
85
|
+
if (!stat.isFile())
|
|
86
|
+
return false;
|
|
87
|
+
// On POSIX, require an execute bit; on win32 existence is enough.
|
|
88
|
+
if (process.platform === "win32")
|
|
89
|
+
return true;
|
|
90
|
+
return (stat.mode & 0o111) !== 0;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Resolves a language to a launchable {@link ServerSpec}, or an explicit
|
|
98
|
+
* "not available" verdict. Config overrides (`lsp.servers.<lang>`, a full
|
|
99
|
+
* command line split on whitespace) win over the default table; the resolved
|
|
100
|
+
* binary must be present or the verdict is `binary-missing`.
|
|
101
|
+
*/
|
|
102
|
+
export class LspRegistry {
|
|
103
|
+
config;
|
|
104
|
+
present;
|
|
105
|
+
constructor(config, present = binaryOnPath) {
|
|
106
|
+
this.config = config;
|
|
107
|
+
this.present = present;
|
|
108
|
+
}
|
|
109
|
+
/** The server spec for a language, from config override or the default table. */
|
|
110
|
+
specFor(language) {
|
|
111
|
+
const override = this.config.servers[language];
|
|
112
|
+
if (override) {
|
|
113
|
+
const parts = override.trim().split(/\s+/);
|
|
114
|
+
const command = parts[0];
|
|
115
|
+
if (!command)
|
|
116
|
+
return null;
|
|
117
|
+
return { language, command, args: parts.slice(1) };
|
|
118
|
+
}
|
|
119
|
+
const def = DEFAULT_SPECS[language];
|
|
120
|
+
return def ? { language, ...def } : null;
|
|
121
|
+
}
|
|
122
|
+
/** Explicit availability verdict — never conflates "no spec" with "no binary". */
|
|
123
|
+
resolve(language) {
|
|
124
|
+
const spec = this.specFor(language);
|
|
125
|
+
if (!spec) {
|
|
126
|
+
return { available: false, language, spec: null, reason: "no-spec" };
|
|
127
|
+
}
|
|
128
|
+
if (!this.present(spec.command)) {
|
|
129
|
+
return { available: false, language, spec, reason: "binary-missing" };
|
|
130
|
+
}
|
|
131
|
+
return { available: true, spec };
|
|
132
|
+
}
|
|
133
|
+
}
|