@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
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { runLspTool } from "./common.js";
|
|
3
|
+
const parameters = z.object({
|
|
4
|
+
file: z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1)
|
|
7
|
+
.describe("Project-relative path to the file to diagnose."),
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* LSP diagnostics (C.12): the language server's errors/warnings for a file.
|
|
11
|
+
* Read-only — no approval. Returns each diagnostic as
|
|
12
|
+
* `severity path:line:col message [source]`, capped to `lsp.maxResults`. A
|
|
13
|
+
* missing server is a coded error; a clean file is an honest empty result.
|
|
14
|
+
*/
|
|
15
|
+
export const getDiagnosticsTool = {
|
|
16
|
+
name: "get_diagnostics",
|
|
17
|
+
description: "Get the language server's diagnostics (errors, warnings) for a file. Give the project-relative path. Returns diagnostics as 'severity path:line:col message'. Read-only, no approval. Use this after an edit to see type errors the compiler/linter reports, without running a build.",
|
|
18
|
+
parameters,
|
|
19
|
+
execute(input, ctx) {
|
|
20
|
+
return runLspTool(ctx, input.file, async (service, absFile) => {
|
|
21
|
+
const diagnostics = await service.diagnostics(absFile);
|
|
22
|
+
if (diagnostics.length === 0) {
|
|
23
|
+
return { ok: true, output: "(no diagnostics)" };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
output: formatDiagnostics(diagnostics, ctx.config.lsp.maxResults),
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
function formatDiagnostics(diagnostics, max) {
|
|
33
|
+
const shown = diagnostics.slice(0, max);
|
|
34
|
+
const lines = shown.map((d) => {
|
|
35
|
+
const src = d.source ? ` [${d.source}]` : "";
|
|
36
|
+
return `${d.severity} ${d.path}:${d.range.startLine}:${d.range.startCol} ${d.message}${src}`;
|
|
37
|
+
});
|
|
38
|
+
const omitted = diagnostics.length - shown.length;
|
|
39
|
+
if (omitted > 0) {
|
|
40
|
+
lines.push(`… [${omitted} more diagnostic(s) omitted]`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Tool } from "../../tools/types.js";
|
|
3
|
+
declare const parameters: z.ZodObject<{
|
|
4
|
+
file: z.ZodString;
|
|
5
|
+
line: z.ZodNumber;
|
|
6
|
+
column: z.ZodNumber;
|
|
7
|
+
}, "strip", z.ZodTypeAny, {
|
|
8
|
+
file: string;
|
|
9
|
+
line: number;
|
|
10
|
+
column: number;
|
|
11
|
+
}, {
|
|
12
|
+
file: string;
|
|
13
|
+
line: number;
|
|
14
|
+
column: number;
|
|
15
|
+
}>;
|
|
16
|
+
/**
|
|
17
|
+
* LSP hover (C.12): type signature / documentation for the symbol at a position.
|
|
18
|
+
* Read-only — no approval. Returns the server's hover text (markup flattened to
|
|
19
|
+
* plain text). A missing server is a coded error; a symbol with no hover info is
|
|
20
|
+
* an honest empty result.
|
|
21
|
+
*/
|
|
22
|
+
export declare const hoverTool: Tool<typeof parameters>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { runLspTool } from "./common.js";
|
|
3
|
+
const parameters = z.object({
|
|
4
|
+
file: z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1)
|
|
7
|
+
.describe("Project-relative path to the file containing the symbol."),
|
|
8
|
+
line: z
|
|
9
|
+
.number()
|
|
10
|
+
.int()
|
|
11
|
+
.positive()
|
|
12
|
+
.describe("1-based line number of the symbol."),
|
|
13
|
+
column: z
|
|
14
|
+
.number()
|
|
15
|
+
.int()
|
|
16
|
+
.positive()
|
|
17
|
+
.describe("1-based column of the symbol (position of the identifier)."),
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* LSP hover (C.12): type signature / documentation for the symbol at a position.
|
|
21
|
+
* Read-only — no approval. Returns the server's hover text (markup flattened to
|
|
22
|
+
* plain text). A missing server is a coded error; a symbol with no hover info is
|
|
23
|
+
* an honest empty result.
|
|
24
|
+
*/
|
|
25
|
+
export const hoverTool = {
|
|
26
|
+
name: "hover",
|
|
27
|
+
description: "Get type information and documentation for a symbol using the project's language server (hover). Give the file and the 1-based line/column of the identifier. Returns the symbol's type signature and docs. Read-only, no approval. Use this to learn a symbol's type without opening and reading its declaration.",
|
|
28
|
+
parameters,
|
|
29
|
+
execute(input, ctx) {
|
|
30
|
+
return runLspTool(ctx, input.file, async (service, absFile) => {
|
|
31
|
+
const hover = await service.hover(absFile, input.line, input.column);
|
|
32
|
+
if (!hover) {
|
|
33
|
+
return { ok: true, output: "(no hover information)" };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, output: hover.contents };
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { LspTransport, ServerSpec } from "./types.js";
|
|
2
|
+
export declare class StdioTransport implements LspTransport {
|
|
3
|
+
private readonly child;
|
|
4
|
+
private nextId;
|
|
5
|
+
private readonly pending;
|
|
6
|
+
private readonly notificationHandlers;
|
|
7
|
+
private crashHandler;
|
|
8
|
+
/** stdout parse buffer (header + body may arrive across chunks). */
|
|
9
|
+
private buffer;
|
|
10
|
+
private disposed;
|
|
11
|
+
/** Deregisters this process from the process-exit kill-tree backstop. */
|
|
12
|
+
private readonly unregisterCleanup;
|
|
13
|
+
constructor(spec: ServerSpec, root: string);
|
|
14
|
+
request(method: string, params: unknown, timeoutMs: number): Promise<unknown>;
|
|
15
|
+
notify(method: string, params: unknown): void;
|
|
16
|
+
onNotification(method: string, handler: (params: unknown) => void): void;
|
|
17
|
+
onCrash(handler: (info: {
|
|
18
|
+
code: number | null;
|
|
19
|
+
signal: string | null;
|
|
20
|
+
}) => void): void;
|
|
21
|
+
dispose(force?: boolean): Promise<void>;
|
|
22
|
+
private send;
|
|
23
|
+
private onStdout;
|
|
24
|
+
private dispatch;
|
|
25
|
+
private onExit;
|
|
26
|
+
private onSpawnError;
|
|
27
|
+
}
|
|
28
|
+
/** A per-request timeout, distinguishable from an ordinary LSP error response. */
|
|
29
|
+
export declare class TransportTimeoutError extends Error {
|
|
30
|
+
readonly method: string;
|
|
31
|
+
readonly timeoutMs: number;
|
|
32
|
+
constructor(method: string, timeoutMs: number);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Kill the process's entire group (POSIX negative-PID `SIGKILL`), same helper
|
|
36
|
+
* shape as run_command's `killTree`. Swallows errors — the process may be gone.
|
|
37
|
+
*/
|
|
38
|
+
export declare function killTree(pid: number | undefined): void;
|
|
39
|
+
/**
|
|
40
|
+
* Force-kill the process group of every tracked-but-not-yet-shut-down server,
|
|
41
|
+
* then forget them. This is exactly what the `exit`/`SIGINT`/`SIGTERM`/`SIGHUP`
|
|
42
|
+
* handlers run — the last line against orphaned language servers on a hard exit.
|
|
43
|
+
* Exported so it is directly testable (like `resetIndexServices`) without having
|
|
44
|
+
* to raise real process signals. Idempotent: a second call is a no-op.
|
|
45
|
+
*/
|
|
46
|
+
export declare function killTrackedServers(): void;
|
|
47
|
+
/** Number of servers currently tracked by the exit backstop (for tests). */
|
|
48
|
+
export declare function trackedServerCount(): number;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/**
|
|
3
|
+
* JSON-RPC 2.0 over a language server's stdio (C.12). Owns the child process:
|
|
4
|
+
* spawns it in its OWN process group (`detached`) so the whole tree is killable,
|
|
5
|
+
* frames messages with `Content-Length` headers, correlates responses to
|
|
6
|
+
* requests by id, times out per request, and detects a crash (unexpected exit).
|
|
7
|
+
*
|
|
8
|
+
* This is the real {@link LspTransport}; tests inject a fake peer instead, so no
|
|
9
|
+
* real language-server binary is required in CI.
|
|
10
|
+
*/
|
|
11
|
+
const GRACE_MS = 2000;
|
|
12
|
+
export class StdioTransport {
|
|
13
|
+
child;
|
|
14
|
+
nextId = 1;
|
|
15
|
+
pending = new Map();
|
|
16
|
+
notificationHandlers = new Map();
|
|
17
|
+
crashHandler = null;
|
|
18
|
+
/** stdout parse buffer (header + body may arrive across chunks). */
|
|
19
|
+
buffer = Buffer.alloc(0);
|
|
20
|
+
disposed = false;
|
|
21
|
+
/** Deregisters this process from the process-exit kill-tree backstop. */
|
|
22
|
+
unregisterCleanup;
|
|
23
|
+
constructor(spec, root) {
|
|
24
|
+
// `detached` makes the child a process-group leader so the whole tree can be
|
|
25
|
+
// killed via a negative-PID signal — same discipline as run_command (C.16).
|
|
26
|
+
this.child = spawn(spec.command, spec.args, {
|
|
27
|
+
cwd: root,
|
|
28
|
+
detached: true,
|
|
29
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
30
|
+
});
|
|
31
|
+
this.unregisterCleanup = registerForCleanup(this.child.pid);
|
|
32
|
+
this.child.stdout?.on("data", (chunk) => this.onStdout(chunk));
|
|
33
|
+
// Server stderr is diagnostic only; surface nothing by default (it's noisy).
|
|
34
|
+
this.child.stderr?.on("data", () => { });
|
|
35
|
+
this.child.on("exit", (code, signal) => this.onExit(code, signal));
|
|
36
|
+
this.child.on("error", (err) => this.onSpawnError(err));
|
|
37
|
+
}
|
|
38
|
+
request(method, params, timeoutMs) {
|
|
39
|
+
if (this.disposed) {
|
|
40
|
+
return Promise.reject(new Error("transport disposed"));
|
|
41
|
+
}
|
|
42
|
+
const id = this.nextId++;
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
this.pending.delete(id);
|
|
46
|
+
reject(new TransportTimeoutError(method, timeoutMs));
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
// Don't let a pending request keep the event loop alive.
|
|
49
|
+
timer.unref?.();
|
|
50
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
51
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
notify(method, params) {
|
|
55
|
+
if (this.disposed)
|
|
56
|
+
return;
|
|
57
|
+
this.send({ jsonrpc: "2.0", method, params });
|
|
58
|
+
}
|
|
59
|
+
onNotification(method, handler) {
|
|
60
|
+
const list = this.notificationHandlers.get(method) ?? [];
|
|
61
|
+
list.push(handler);
|
|
62
|
+
this.notificationHandlers.set(method, list);
|
|
63
|
+
}
|
|
64
|
+
onCrash(handler) {
|
|
65
|
+
this.crashHandler = handler;
|
|
66
|
+
}
|
|
67
|
+
async dispose(force = false) {
|
|
68
|
+
if (this.disposed)
|
|
69
|
+
return;
|
|
70
|
+
this.disposed = true;
|
|
71
|
+
this.unregisterCleanup();
|
|
72
|
+
// Reject anything still in flight so callers never hang on teardown.
|
|
73
|
+
for (const [, p] of this.pending) {
|
|
74
|
+
clearTimeout(p.timer);
|
|
75
|
+
p.reject(new Error("transport disposed"));
|
|
76
|
+
}
|
|
77
|
+
this.pending.clear();
|
|
78
|
+
if (!this.child.pid || this.child.exitCode !== null)
|
|
79
|
+
return;
|
|
80
|
+
if (force) {
|
|
81
|
+
killTree(this.child.pid);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Give the process a grace window to exit on its own (the caller has
|
|
85
|
+
// already sent the LSP `exit` notification), then force-kill the group.
|
|
86
|
+
await new Promise((resolve) => {
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
killTree(this.child.pid);
|
|
89
|
+
resolve();
|
|
90
|
+
}, GRACE_MS);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
this.child.once("exit", () => {
|
|
93
|
+
clearTimeout(timer);
|
|
94
|
+
resolve();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// ── framing ─────────────────────────────────────────────────────────────────
|
|
99
|
+
send(message) {
|
|
100
|
+
const body = Buffer.from(JSON.stringify(message), "utf8");
|
|
101
|
+
const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii");
|
|
102
|
+
this.child.stdin?.write(Buffer.concat([header, body]));
|
|
103
|
+
}
|
|
104
|
+
onStdout(chunk) {
|
|
105
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
106
|
+
// Drain every fully-received message currently in the buffer.
|
|
107
|
+
for (;;) {
|
|
108
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
109
|
+
if (headerEnd === -1)
|
|
110
|
+
return;
|
|
111
|
+
const header = this.buffer.subarray(0, headerEnd).toString("ascii");
|
|
112
|
+
const match = /content-length:\s*(\d+)/i.exec(header);
|
|
113
|
+
if (!match) {
|
|
114
|
+
// Malformed header — drop it and resync past the separator.
|
|
115
|
+
this.buffer = this.buffer.subarray(headerEnd + 4);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const length = Number(match[1]);
|
|
119
|
+
const bodyStart = headerEnd + 4;
|
|
120
|
+
if (this.buffer.length < bodyStart + length)
|
|
121
|
+
return; // body not complete yet
|
|
122
|
+
const body = this.buffer.subarray(bodyStart, bodyStart + length);
|
|
123
|
+
this.buffer = this.buffer.subarray(bodyStart + length);
|
|
124
|
+
this.dispatch(body.toString("utf8"));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
dispatch(text) {
|
|
128
|
+
let msg;
|
|
129
|
+
try {
|
|
130
|
+
msg = JSON.parse(text);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return; // ignore unparseable frames
|
|
134
|
+
}
|
|
135
|
+
// Response to one of our requests.
|
|
136
|
+
if (typeof msg.id === "number" && (msg.result !== undefined || msg.error)) {
|
|
137
|
+
const pending = this.pending.get(msg.id);
|
|
138
|
+
if (!pending)
|
|
139
|
+
return;
|
|
140
|
+
this.pending.delete(msg.id);
|
|
141
|
+
clearTimeout(pending.timer);
|
|
142
|
+
if (msg.error)
|
|
143
|
+
pending.reject(new Error(msg.error.message ?? "LSP error"));
|
|
144
|
+
else
|
|
145
|
+
pending.resolve(msg.result);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// Server → client notification (e.g. textDocument/publishDiagnostics).
|
|
149
|
+
if (typeof msg.method === "string" && msg.id === undefined) {
|
|
150
|
+
for (const handler of this.notificationHandlers.get(msg.method) ?? []) {
|
|
151
|
+
handler(msg.params);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// Server → client requests (e.g. workspace/configuration) are unsupported;
|
|
155
|
+
// we intentionally don't reply — our advertised capabilities don't ask for
|
|
156
|
+
// features that require a mandatory response.
|
|
157
|
+
}
|
|
158
|
+
onExit(code, signal) {
|
|
159
|
+
this.unregisterCleanup();
|
|
160
|
+
if (this.disposed)
|
|
161
|
+
return; // an expected shutdown, not a crash
|
|
162
|
+
// Unexpected exit → a crash. The server's OWN child processes (e.g. gopls's
|
|
163
|
+
// `go`, rust-analyzer's `cargo`) can outlive it — reap the whole group so a
|
|
164
|
+
// crash never orphans them. The group persists as long as any member lives,
|
|
165
|
+
// so the negative-pid signal still lands even though the leader is gone.
|
|
166
|
+
killTree(this.child.pid);
|
|
167
|
+
// Reject everything in flight, then notify.
|
|
168
|
+
for (const [, p] of this.pending) {
|
|
169
|
+
clearTimeout(p.timer);
|
|
170
|
+
p.reject(new Error(`language server exited (code ${code}, signal ${signal})`));
|
|
171
|
+
}
|
|
172
|
+
this.pending.clear();
|
|
173
|
+
this.crashHandler?.({ code, signal });
|
|
174
|
+
}
|
|
175
|
+
onSpawnError(err) {
|
|
176
|
+
// The binary vanished between the presence check and spawn, or is not
|
|
177
|
+
// executable. Treat as a crash so the pool surfaces a coded error.
|
|
178
|
+
if (this.disposed)
|
|
179
|
+
return;
|
|
180
|
+
for (const [, p] of this.pending) {
|
|
181
|
+
clearTimeout(p.timer);
|
|
182
|
+
p.reject(err);
|
|
183
|
+
}
|
|
184
|
+
this.pending.clear();
|
|
185
|
+
this.crashHandler?.({ code: null, signal: null });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** A per-request timeout, distinguishable from an ordinary LSP error response. */
|
|
189
|
+
export class TransportTimeoutError extends Error {
|
|
190
|
+
method;
|
|
191
|
+
timeoutMs;
|
|
192
|
+
constructor(method, timeoutMs) {
|
|
193
|
+
super(`LSP request "${method}" timed out after ${timeoutMs}ms`);
|
|
194
|
+
this.method = method;
|
|
195
|
+
this.timeoutMs = timeoutMs;
|
|
196
|
+
this.name = "TransportTimeoutError";
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Kill the process's entire group (POSIX negative-PID `SIGKILL`), same helper
|
|
201
|
+
* shape as run_command's `killTree`. Swallows errors — the process may be gone.
|
|
202
|
+
*/
|
|
203
|
+
export function killTree(pid) {
|
|
204
|
+
if (pid === undefined)
|
|
205
|
+
return;
|
|
206
|
+
try {
|
|
207
|
+
process.kill(-pid, "SIGKILL");
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
try {
|
|
211
|
+
// Fall back to a direct kill if there was no group (or on win32).
|
|
212
|
+
process.kill(pid, "SIGKILL");
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
/* already exited */
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// ── process-exit kill-tree backstop ───────────────────────────────────────────
|
|
220
|
+
//
|
|
221
|
+
// Language servers are long-lived child processes. A per-session shutdown covers
|
|
222
|
+
// the normal path, but a hard exit (Ctrl-C, an uncaught throw) would otherwise
|
|
223
|
+
// orphan them — so every live server's process group is tracked here and killed
|
|
224
|
+
// on process teardown. Handlers are registered ONCE, lazily, on the first spawn,
|
|
225
|
+
// so unit tests using the fake transport never install them.
|
|
226
|
+
const livePids = new Set();
|
|
227
|
+
let handlersInstalled = false;
|
|
228
|
+
/**
|
|
229
|
+
* Force-kill the process group of every tracked-but-not-yet-shut-down server,
|
|
230
|
+
* then forget them. This is exactly what the `exit`/`SIGINT`/`SIGTERM`/`SIGHUP`
|
|
231
|
+
* handlers run — the last line against orphaned language servers on a hard exit.
|
|
232
|
+
* Exported so it is directly testable (like `resetIndexServices`) without having
|
|
233
|
+
* to raise real process signals. Idempotent: a second call is a no-op.
|
|
234
|
+
*/
|
|
235
|
+
export function killTrackedServers() {
|
|
236
|
+
for (const pid of livePids)
|
|
237
|
+
killTree(pid);
|
|
238
|
+
livePids.clear();
|
|
239
|
+
}
|
|
240
|
+
/** Number of servers currently tracked by the exit backstop (for tests). */
|
|
241
|
+
export function trackedServerCount() {
|
|
242
|
+
return livePids.size;
|
|
243
|
+
}
|
|
244
|
+
function installExitHandlers() {
|
|
245
|
+
if (handlersInstalled)
|
|
246
|
+
return;
|
|
247
|
+
handlersInstalled = true;
|
|
248
|
+
process.once("exit", killTrackedServers);
|
|
249
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
250
|
+
process.once(sig, () => {
|
|
251
|
+
killTrackedServers();
|
|
252
|
+
// Restore default behavior and re-raise so the exit code is correct.
|
|
253
|
+
process.exit(130);
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/** Track a live child for the exit backstop; returns a deregister callback. */
|
|
258
|
+
function registerForCleanup(pid) {
|
|
259
|
+
if (pid === undefined)
|
|
260
|
+
return () => { };
|
|
261
|
+
installExitHandlers();
|
|
262
|
+
livePids.add(pid);
|
|
263
|
+
return () => livePids.delete(pid);
|
|
264
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { LspConfig } from "../config/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Per-language LSP integration (C.12) — the shared vocabulary. Results here are
|
|
4
|
+
* NORMALIZED (1-based lines/columns, project-relative paths, plain severities):
|
|
5
|
+
* the LSP wire format's 0-based positions and `file://` URIs are converted at
|
|
6
|
+
* the edge (`server.ts`) so nothing downstream speaks raw LSP.
|
|
7
|
+
*/
|
|
8
|
+
export type { LspConfig };
|
|
9
|
+
/**
|
|
10
|
+
* A source range, normalized to 1-based lines and columns (LSP is 0-based; we
|
|
11
|
+
* add 1 at the boundary). `path` is project-relative for display.
|
|
12
|
+
*/
|
|
13
|
+
export interface LspLocation {
|
|
14
|
+
/** Project-relative file path. */
|
|
15
|
+
path: string;
|
|
16
|
+
/** 1-based start line. */
|
|
17
|
+
startLine: number;
|
|
18
|
+
/** 1-based start column. */
|
|
19
|
+
startCol: number;
|
|
20
|
+
/** 1-based end line. */
|
|
21
|
+
endLine: number;
|
|
22
|
+
/** 1-based end column. */
|
|
23
|
+
endCol: number;
|
|
24
|
+
}
|
|
25
|
+
/** A normalized diagnostic (severity mapped off the LSP 1–4 integer scale). */
|
|
26
|
+
export interface LspDiagnostic {
|
|
27
|
+
/** Project-relative file path. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Where the diagnostic applies (1-based). */
|
|
30
|
+
range: LspLocation;
|
|
31
|
+
severity: "error" | "warning" | "info" | "hint";
|
|
32
|
+
message: string;
|
|
33
|
+
/** The producing tool, when the server reports it (e.g. "tsc", "gopls"). */
|
|
34
|
+
source?: string;
|
|
35
|
+
}
|
|
36
|
+
/** A normalized hover result — markdown/markup flattened to plain text. */
|
|
37
|
+
export interface LspHover {
|
|
38
|
+
contents: string;
|
|
39
|
+
/** The symbol range the hover describes, when the server reports it. */
|
|
40
|
+
range?: LspLocation;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A single, initialized language server (≈ one live child process). Queries are
|
|
44
|
+
* absolute-path in / normalized-out; the client relativizes paths against the
|
|
45
|
+
* workspace root. A method resolving to `[]`/`null` is an honest "no result" —
|
|
46
|
+
* unavailability is signalled by a thrown coded error from the pool, never here.
|
|
47
|
+
*/
|
|
48
|
+
export interface LanguageServer {
|
|
49
|
+
readonly language: string;
|
|
50
|
+
/** True until the process exits (crash detection flips this to false). */
|
|
51
|
+
readonly alive: boolean;
|
|
52
|
+
definition(file: string, line: number, col: number): Promise<LspLocation[]>;
|
|
53
|
+
references(file: string, line: number, col: number): Promise<LspLocation[]>;
|
|
54
|
+
diagnostics(file: string): Promise<LspDiagnostic[]>;
|
|
55
|
+
hover(file: string, line: number, col: number): Promise<LspHover | null>;
|
|
56
|
+
/** LSP `shutdown` → `exit`, then force-kill the process group after a grace. */
|
|
57
|
+
shutdown(force?: boolean): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
/** How to launch one language's server, resolved from config ∪ the default table. */
|
|
60
|
+
export interface ServerSpec {
|
|
61
|
+
language: string;
|
|
62
|
+
/** Executable name or absolute path. */
|
|
63
|
+
command: string;
|
|
64
|
+
/** Argument vector (e.g. `["--stdio"]`). */
|
|
65
|
+
args: string[];
|
|
66
|
+
/** Concrete install instruction, surfaced when the binary is missing. */
|
|
67
|
+
installHint?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The registry's answer for a language. Availability is EXPLICIT: an
|
|
71
|
+
* unavailable server carries the reason so the caller raises a coded,
|
|
72
|
+
* actionable error rather than silently returning nothing.
|
|
73
|
+
*/
|
|
74
|
+
export type ServerResolution = {
|
|
75
|
+
available: true;
|
|
76
|
+
spec: ServerSpec;
|
|
77
|
+
} | {
|
|
78
|
+
available: false;
|
|
79
|
+
language: string;
|
|
80
|
+
spec: ServerSpec | null;
|
|
81
|
+
reason: "no-spec" | "binary-missing";
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* The one seam tests replace: a live JSON-RPC peer over the server's stdio. A
|
|
85
|
+
* fake implementation lets the whole stack run with NO real language-server
|
|
86
|
+
* binary (mirroring `RecordingRuntime` for the sandbox).
|
|
87
|
+
*/
|
|
88
|
+
export interface LspTransport {
|
|
89
|
+
/** Send a request and await its response, rejecting after `timeoutMs`. */
|
|
90
|
+
request(method: string, params: unknown, timeoutMs: number): Promise<unknown>;
|
|
91
|
+
/** Fire-and-forget notification (no response expected). */
|
|
92
|
+
notify(method: string, params: unknown): void;
|
|
93
|
+
/** Register a handler for a server→client notification (e.g. diagnostics). */
|
|
94
|
+
onNotification(method: string, handler: (params: unknown) => void): void;
|
|
95
|
+
/** Register a one-shot crash callback (process exited unexpectedly). */
|
|
96
|
+
onCrash(handler: (info: {
|
|
97
|
+
code: number | null;
|
|
98
|
+
signal: string | null;
|
|
99
|
+
}) => void): void;
|
|
100
|
+
/** LSP graceful path already sent by the caller; drop the transport,
|
|
101
|
+
* force-killing the process group after the grace when `force` is set. */
|
|
102
|
+
dispose(force?: boolean): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
/** Builds a transport for a resolved spec, rooted at `root`. Injectable in tests. */
|
|
105
|
+
export type TransportFactory = (spec: ServerSpec, root: string) => LspTransport;
|
|
106
|
+
/** Predicate: is `command` runnable (on PATH, or an existing executable path)? */
|
|
107
|
+
export type BinaryPresent = (command: string) => boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/plan/service.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Message, Provider } from "@cruxy/sdk";
|
|
1
|
+
import type { Message, Provider, Usage } from "@cruxy/sdk";
|
|
2
2
|
import type { CruxyConfig } from "../config/index.js";
|
|
3
3
|
import type { PromptIO } from "../approval/index.js";
|
|
4
4
|
import { ToolRegistry, type ToolContext } from "../tools/index.js";
|
|
@@ -45,5 +45,14 @@ export interface PlanSessionArgs {
|
|
|
45
45
|
* and step execution on `main-turn`; omitted → the provider default.
|
|
46
46
|
*/
|
|
47
47
|
router?: Router;
|
|
48
|
+
/**
|
|
49
|
+
* Usage telemetry (C.22): forwarded to every model request the plan turn
|
|
50
|
+
* drives (propose + each execution step), so plan-mode usage is captured and
|
|
51
|
+
* tier-attributed exactly like a normal turn.
|
|
52
|
+
*/
|
|
53
|
+
onRequestUsage?: (req: {
|
|
54
|
+
tier?: string;
|
|
55
|
+
usage?: Usage;
|
|
56
|
+
}) => void;
|
|
48
57
|
}
|
|
49
58
|
export declare function runPlanSession(args: PlanSessionArgs): Promise<AgentResult>;
|
package/dist/plan/service.js
CHANGED
|
@@ -78,6 +78,7 @@ export async function runPlanSession(args) {
|
|
|
78
78
|
planMode: true,
|
|
79
79
|
router: args.router,
|
|
80
80
|
taskClass: "plan",
|
|
81
|
+
onRequestUsage: args.onRequestUsage,
|
|
81
82
|
}));
|
|
82
83
|
if (!holder.plan) {
|
|
83
84
|
throw planInvalid("the model ended its turn without calling submit_plan");
|
|
@@ -112,6 +113,7 @@ export async function runPlanSession(args) {
|
|
|
112
113
|
renderer: args.renderer,
|
|
113
114
|
router: args.router,
|
|
114
115
|
taskClass: "main-turn",
|
|
116
|
+
onRequestUsage: args.onRequestUsage,
|
|
115
117
|
}));
|
|
116
118
|
};
|
|
117
119
|
await executePlan(plan, {
|
|
@@ -9,15 +9,15 @@ declare const parameters: z.ZodObject<{
|
|
|
9
9
|
}, "strip", z.ZodTypeAny, {
|
|
10
10
|
pattern: string;
|
|
11
11
|
path?: string | undefined;
|
|
12
|
+
maxResults?: number | undefined;
|
|
12
13
|
glob?: string | undefined;
|
|
13
14
|
ignoreCase?: boolean | undefined;
|
|
14
|
-
maxResults?: number | undefined;
|
|
15
15
|
}, {
|
|
16
16
|
pattern: string;
|
|
17
17
|
path?: string | undefined;
|
|
18
|
+
maxResults?: number | undefined;
|
|
18
19
|
glob?: string | undefined;
|
|
19
20
|
ignoreCase?: boolean | undefined;
|
|
20
|
-
maxResults?: number | undefined;
|
|
21
21
|
}>;
|
|
22
22
|
/**
|
|
23
23
|
* Search file *contents* for a regex within the project root. Read-only — no
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { Usage } from "@cruxy/sdk";
|
|
2
|
+
import type { UsageRecord } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Usage collection (C.22). Accumulates per-request usage exactly as the agent
|
|
5
|
+
* loop reports it — one {@link UsageEntry} per completed model request — and
|
|
6
|
+
* emits a {@link UsageRecord} for the run.
|
|
7
|
+
*
|
|
8
|
+
* The one honesty invariant: `usage: undefined` (the loop's signal that the
|
|
9
|
+
* provider returned NO usage event for a request) is recorded as `undefined`
|
|
10
|
+
* token counts — the honest "unknown". A provider-reported `0` arrives as a real
|
|
11
|
+
* `Usage` and is stored as `0`. Nothing is estimated, re-tokenized, or
|
|
12
|
+
* zero-filled, and this module makes ZERO network calls.
|
|
13
|
+
*/
|
|
14
|
+
/** What the loop hands over for one completed request. */
|
|
15
|
+
export interface RequestUsage {
|
|
16
|
+
/** The routing tier (C.30) the request ran on, if routing was active. */
|
|
17
|
+
tier?: string;
|
|
18
|
+
/**
|
|
19
|
+
* The provider's usage for THIS request, or `undefined` when the provider
|
|
20
|
+
* emitted no usage event (⇒ tokens are unknown, not zero).
|
|
21
|
+
*/
|
|
22
|
+
usage?: Usage;
|
|
23
|
+
}
|
|
24
|
+
/** Wall clock as an injectable seam so tests are deterministic. */
|
|
25
|
+
export type Clock = () => string;
|
|
26
|
+
export declare class UsageCollector {
|
|
27
|
+
private readonly now;
|
|
28
|
+
private readonly entries;
|
|
29
|
+
constructor(now?: Clock);
|
|
30
|
+
/**
|
|
31
|
+
* Record one completed request. When `req.usage` is absent the entry's token
|
|
32
|
+
* counts stay `undefined` — the provider reported nothing, so we assert
|
|
33
|
+
* nothing. A real reported `0` is preserved as `0`.
|
|
34
|
+
*/
|
|
35
|
+
record(req: RequestUsage): void;
|
|
36
|
+
/** How many requests have been recorded so far. */
|
|
37
|
+
get count(): number;
|
|
38
|
+
/** Snapshot the collected entries into a persistable {@link UsageRecord}. */
|
|
39
|
+
toRecord(runId: string, sessionId: string | undefined, startedAt: string): UsageRecord;
|
|
40
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const systemClock = () => new Date().toISOString();
|
|
2
|
+
export class UsageCollector {
|
|
3
|
+
now;
|
|
4
|
+
entries = [];
|
|
5
|
+
constructor(now = systemClock) {
|
|
6
|
+
this.now = now;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Record one completed request. When `req.usage` is absent the entry's token
|
|
10
|
+
* counts stay `undefined` — the provider reported nothing, so we assert
|
|
11
|
+
* nothing. A real reported `0` is preserved as `0`.
|
|
12
|
+
*/
|
|
13
|
+
record(req) {
|
|
14
|
+
this.entries.push({
|
|
15
|
+
tier: req.tier,
|
|
16
|
+
inputTokens: req.usage?.input_tokens,
|
|
17
|
+
outputTokens: req.usage?.output_tokens,
|
|
18
|
+
at: this.now(),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/** How many requests have been recorded so far. */
|
|
22
|
+
get count() {
|
|
23
|
+
return this.entries.length;
|
|
24
|
+
}
|
|
25
|
+
/** Snapshot the collected entries into a persistable {@link UsageRecord}. */
|
|
26
|
+
toRecord(runId, sessionId, startedAt) {
|
|
27
|
+
return {
|
|
28
|
+
runId,
|
|
29
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
30
|
+
startedAt,
|
|
31
|
+
entries: [...this.entries],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|