micro-models-agent 0.51.1 → 0.52.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/cli/commands.js +162 -38
- package/dist/cli/completer.js +5 -5
- package/dist/cli/main.js +42 -54
- package/dist/cli/repl-commands.js +138 -38
- package/dist/cli/repl.js +175 -89
- package/dist/cli/run-result.js +11 -0
- package/dist/cli/security-commands.js +6 -6
- package/dist/cli/setup.js +21 -15
- package/dist/config/config.js +54 -27
- package/dist/config/defaults.js +17 -0
- package/dist/config/domains.js +179 -0
- package/dist/config/index.js +2 -1
- package/dist/config/security.js +28 -8
- package/dist/core/agent.js +162 -30
- package/dist/core/bootstrap.js +94 -17
- package/dist/core/crash-handler.js +51 -0
- package/dist/core/environment.js +199 -0
- package/dist/core/session-logger.js +60 -6
- package/dist/core/version.js +2 -0
- package/dist/i18n/en.json +120 -39
- package/dist/i18n/ru.json +91 -10
- package/dist/llm/openai-compat.js +191 -53
- package/dist/llm/orchestrator.js +5 -3
- package/dist/logger/app-logger.js +50 -4
- package/dist/main.js +1288 -635
- package/dist/modules/browser/session.js +4 -0
- package/dist/modules/certification/cli.js +58 -19
- package/dist/modules/certification/loader.js +2 -1
- package/dist/modules/certification/manifest.js +22 -14
- package/dist/modules/certification/runner.js +91 -5
- package/dist/modules/certification/scenarios.js +290 -7
- package/dist/modules/context/fact-extractor.js +6 -0
- package/dist/modules/context/manager.js +19 -2
- package/dist/modules/execution/audit-runners.js +61 -7
- package/dist/modules/execution/execution-plugin.js +219 -60
- package/dist/modules/execution/module.js +207 -18
- package/dist/modules/execution/moe-executor.js +33 -20
- package/dist/modules/execution/plan-store.js +39 -0
- package/dist/modules/execution/plan-tool.js +188 -19
- package/dist/modules/execution/planner.js +27 -23
- package/dist/modules/execution/stuck-detector.js +244 -8
- package/dist/modules/execution/tracker.js +8 -6
- package/dist/modules/execution/verifier.js +15 -2
- package/dist/modules/hallucination/detector.js +4 -0
- package/dist/modules/hallucination/factual.js +45 -5
- package/dist/modules/indexer/module.js +1 -0
- package/dist/modules/lsp/client.js +123 -12
- package/dist/modules/lsp/index.js +1 -1
- package/dist/modules/lsp/module.js +30 -2
- package/dist/modules/lsp/probe.js +11 -1
- package/dist/modules/lsp/startup-check.js +5 -2
- package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
- package/dist/modules/plugins/manager.js +57 -13
- package/dist/modules/pricing/index.js +61 -0
- package/dist/modules/pricing/prices.js +129 -0
- package/dist/modules/providers/create.js +22 -0
- package/dist/modules/providers/fallback.js +79 -0
- package/dist/modules/providers/health.js +46 -0
- package/dist/modules/providers/index.js +5 -0
- package/dist/modules/providers/manager.js +161 -0
- package/dist/modules/providers/presets.js +128 -0
- package/dist/modules/providers/registry.js +22 -0
- package/dist/modules/providers/types.js +1 -0
- package/dist/modules/registry.js +1 -0
- package/dist/modules/security/command-validator.js +14 -0
- package/dist/modules/security/encryption.js +6 -6
- package/dist/modules/security/network-validator.js +17 -0
- package/dist/modules/security/path-validator.js +22 -26
- package/dist/modules/session/store.js +10 -10
- package/dist/tools/approve.js +1 -0
- package/dist/tools/attach-image.js +12 -0
- package/dist/tools/bash.js +27 -4
- package/dist/tools/browser.js +1 -0
- package/dist/tools/chunk-query.js +1 -0
- package/dist/tools/create-dir.js +1 -0
- package/dist/tools/delete-file.js +1 -0
- package/dist/tools/download-file.js +1 -0
- package/dist/tools/edit-file.js +2 -1
- package/dist/tools/enable-tools.js +1 -0
- package/dist/tools/executor.js +17 -7
- package/dist/tools/file-info.js +1 -0
- package/dist/tools/glob-tool.js +1 -0
- package/dist/tools/grep-tool.js +54 -13
- package/dist/tools/list-dir.js +1 -0
- package/dist/tools/load-skill.js +1 -0
- package/dist/tools/mcp-call.js +1 -0
- package/dist/tools/move-file.js +1 -0
- package/dist/tools/path-utils.js +51 -1
- package/dist/tools/pipeline-run.js +1 -0
- package/dist/tools/process-kill.js +11 -0
- package/dist/tools/process-list.js +1 -0
- package/dist/tools/process-log.js +9 -0
- package/dist/tools/question.js +1 -0
- package/dist/tools/read-file.js +94 -6
- package/dist/tools/recall.js +1 -0
- package/dist/tools/remember.js +1 -0
- package/dist/tools/scope-check.js +7 -5
- package/dist/tools/search-history.js +1 -0
- package/dist/tools/subagent.js +4 -4
- package/dist/tools/web-browse.js +1 -0
- package/dist/tools/web-fetch.js +27 -6
- package/dist/tools/web-search.js +70 -43
- package/dist/tools/write-file.js +1 -0
- package/dist/ui/line-editor.js +142 -23
- package/dist/ui/line-math.js +8 -4
- package/dist/ui/renderer.js +57 -7
- package/package.json +50 -48
|
@@ -3,8 +3,36 @@ import { resolve } from "path";
|
|
|
3
3
|
import { platform } from "os";
|
|
4
4
|
import { resolveSpawnCommand, buildWinCommandLine } from "./command";
|
|
5
5
|
import { killTree } from "../processes/registry";
|
|
6
|
+
/**
|
|
7
|
+
* Logger adapter that writes LSP diagnostics to app.jsonl only — never to
|
|
8
|
+
* console/terminal. LSP warnings are noisy (timeout on first run, npx
|
|
9
|
+
* install) and should be diagnosable from session files without polluting
|
|
10
|
+
* the user's chat.
|
|
11
|
+
*/
|
|
12
|
+
export class FileOnlyLogger {
|
|
13
|
+
logger;
|
|
14
|
+
constructor(logger) {
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
}
|
|
17
|
+
debug(msg, meta) {
|
|
18
|
+
this.logger.logSilent("debug", msg, meta);
|
|
19
|
+
}
|
|
20
|
+
info(msg, meta) {
|
|
21
|
+
this.logger.logSilent("info", msg, meta);
|
|
22
|
+
}
|
|
23
|
+
warn(msg, meta) {
|
|
24
|
+
this.logger.logSilent("warn", msg, meta);
|
|
25
|
+
}
|
|
26
|
+
error(msg, meta) {
|
|
27
|
+
this.logger.logSilent("error", msg, meta);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
6
30
|
const DEFAULT_TIMEOUT = 15000;
|
|
7
31
|
export class LspClient {
|
|
32
|
+
logger = null;
|
|
33
|
+
setLogger(logger) {
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
}
|
|
8
36
|
process = null;
|
|
9
37
|
requestId = 0;
|
|
10
38
|
pending = new Map();
|
|
@@ -75,12 +103,27 @@ export class LspClient {
|
|
|
75
103
|
await this.sendRequest("initialize", initParams, timeout);
|
|
76
104
|
}
|
|
77
105
|
catch (e) {
|
|
106
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
107
|
+
this.logger?.warn(`LSP initialize failed (attempt 1): ${msg}`, {
|
|
108
|
+
command: config.command,
|
|
109
|
+
projectRoot,
|
|
110
|
+
});
|
|
78
111
|
// npx auto-install can blow past the initialize timeout on first run — retry once.
|
|
79
112
|
if (!(e instanceof Error) || !e.message.includes("initialize"))
|
|
80
113
|
throw e;
|
|
81
114
|
await this.shutdown();
|
|
82
115
|
await this.startServer(config, projectRoot);
|
|
83
|
-
|
|
116
|
+
try {
|
|
117
|
+
await this.sendRequest("initialize", initParams, timeout);
|
|
118
|
+
}
|
|
119
|
+
catch (retryErr) {
|
|
120
|
+
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
121
|
+
this.logger?.error(`LSP initialize failed (attempt 2, giving up): ${retryMsg}`, {
|
|
122
|
+
command: config.command,
|
|
123
|
+
projectRoot,
|
|
124
|
+
});
|
|
125
|
+
throw retryErr;
|
|
126
|
+
}
|
|
84
127
|
}
|
|
85
128
|
this.initialized = true;
|
|
86
129
|
}
|
|
@@ -93,10 +136,32 @@ export class LspClient {
|
|
|
93
136
|
throw new Error(`${config.command} not found in PATH`);
|
|
94
137
|
}
|
|
95
138
|
}
|
|
139
|
+
// When the command is npx, try to find the actual binary globally first.
|
|
140
|
+
// npx downloads packages on every invocation (slow on Windows, ~8-15s),
|
|
141
|
+
// even when the binary is already installed globally.
|
|
142
|
+
let effectiveCommand = config.command;
|
|
143
|
+
let effectiveArgs = config.args ?? [];
|
|
144
|
+
if (config.command === "npx" && effectiveArgs.length > 0) {
|
|
145
|
+
const binaryName = this.extractBinaryFromNpxArgs(effectiveArgs);
|
|
146
|
+
if (binaryName) {
|
|
147
|
+
const whichCmd = platform() === "win32" ? `where ${binaryName}` : `which ${binaryName}`;
|
|
148
|
+
try {
|
|
149
|
+
execSync(whichCmd, { stdio: "pipe", timeout: 3000 });
|
|
150
|
+
// Binary found globally — use it directly, skip npx.
|
|
151
|
+
effectiveCommand = binaryName;
|
|
152
|
+
effectiveArgs = effectiveArgs.filter((a) => a !== "--yes" && a !== "--package" && a !== binaryName && !this.isPackageVersion(a));
|
|
153
|
+
this.logger?.debug(`LSP: using globally installed ${binaryName} (skipped npx)`);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
// Not found globally — fall back to npx (will auto-install).
|
|
157
|
+
this.logger?.debug(`LSP: ${binaryName} not found globally, falling back to npx`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
96
161
|
return new Promise((resolve, reject) => {
|
|
97
|
-
const args =
|
|
162
|
+
const args = effectiveArgs;
|
|
98
163
|
const isWin = platform() === "win32";
|
|
99
|
-
let spawnCommand = resolveSpawnCommand(
|
|
164
|
+
let spawnCommand = resolveSpawnCommand(effectiveCommand);
|
|
100
165
|
let spawnArgs = args;
|
|
101
166
|
const spawnOpts = {
|
|
102
167
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -120,8 +185,11 @@ export class LspClient {
|
|
|
120
185
|
});
|
|
121
186
|
this.process = proc;
|
|
122
187
|
setTimeout(() => {
|
|
123
|
-
if (!this.initialized && this.process)
|
|
124
|
-
|
|
188
|
+
if (!this.initialized && this.process) {
|
|
189
|
+
const msg = "LSP server start timeout";
|
|
190
|
+
this.logger?.error(msg, { command: config.command, projectRoot, timeout: config.timeout ?? 10000 });
|
|
191
|
+
reject(new Error(msg));
|
|
192
|
+
}
|
|
125
193
|
}, config.timeout ?? 10000);
|
|
126
194
|
});
|
|
127
195
|
}
|
|
@@ -160,7 +228,9 @@ export class LspClient {
|
|
|
160
228
|
const msg = JSON.parse(body);
|
|
161
229
|
this.handleMessage(msg);
|
|
162
230
|
}
|
|
163
|
-
catch {
|
|
231
|
+
catch (e) {
|
|
232
|
+
this.logger?.debug(`LSP JSON parse error: ${e instanceof Error ? e.message : String(e)}`);
|
|
233
|
+
}
|
|
164
234
|
}
|
|
165
235
|
}
|
|
166
236
|
handleMessage(msg) {
|
|
@@ -199,7 +269,9 @@ export class LspClient {
|
|
|
199
269
|
setTimeout(() => {
|
|
200
270
|
if (this.pending.has(id)) {
|
|
201
271
|
this.pending.delete(id);
|
|
202
|
-
|
|
272
|
+
const msg = `LSP request timeout: ${method}`;
|
|
273
|
+
this.logger?.warn(msg, { method, timeout });
|
|
274
|
+
reject(new Error(msg));
|
|
203
275
|
}
|
|
204
276
|
}, timeout);
|
|
205
277
|
});
|
|
@@ -209,23 +281,37 @@ export class LspClient {
|
|
|
209
281
|
this.write(message);
|
|
210
282
|
}
|
|
211
283
|
write(message) {
|
|
212
|
-
if (!this.process?.stdin?.writable)
|
|
284
|
+
if (!this.process?.stdin?.writable) {
|
|
285
|
+
this.logger?.debug("LSP write failed: process stdin not writable");
|
|
213
286
|
return;
|
|
287
|
+
}
|
|
214
288
|
const header = `Content-Length: ${Buffer.byteLength(message)}\r\n\r\n`;
|
|
215
289
|
try {
|
|
216
290
|
this.process.stdin.write(header + message);
|
|
217
291
|
}
|
|
218
|
-
catch {
|
|
292
|
+
catch (e) {
|
|
293
|
+
this.logger?.debug(`LSP write error: ${e instanceof Error ? e.message : String(e)}`);
|
|
294
|
+
}
|
|
219
295
|
}
|
|
220
296
|
async shutdown() {
|
|
221
297
|
try {
|
|
222
298
|
if (this.process && this.initialized && this.process.stdin?.writable) {
|
|
223
|
-
|
|
299
|
+
// Await the shutdown response (with timeout) so the server has time to
|
|
300
|
+
// acknowledge before we send exit and kill the process. The old code
|
|
301
|
+
// fired shutdown as fire-and-forget, then killed immediately — the
|
|
302
|
+
// server never had a chance to reply, producing spurious timeout warnings.
|
|
303
|
+
try {
|
|
304
|
+
await this.sendRequest("shutdown", null, 3000);
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
this.logger?.debug(`LSP shutdown request failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
308
|
+
}
|
|
224
309
|
this.sendNotification("exit", null);
|
|
225
310
|
await new Promise((r) => setTimeout(r, 200));
|
|
226
311
|
}
|
|
227
312
|
}
|
|
228
|
-
catch {
|
|
313
|
+
catch (e) {
|
|
314
|
+
this.logger?.debug(`LSP shutdown error: ${e instanceof Error ? e.message : String(e)}`);
|
|
229
315
|
}
|
|
230
316
|
finally {
|
|
231
317
|
if (this.process) {
|
|
@@ -235,7 +321,9 @@ export class LspClient {
|
|
|
235
321
|
try {
|
|
236
322
|
killTree(this.process);
|
|
237
323
|
}
|
|
238
|
-
catch {
|
|
324
|
+
catch (e) {
|
|
325
|
+
this.logger?.debug(`LSP killTree error: ${e instanceof Error ? e.message : String(e)}`);
|
|
326
|
+
}
|
|
239
327
|
this.process = null;
|
|
240
328
|
}
|
|
241
329
|
this.initialized = false;
|
|
@@ -275,4 +363,27 @@ export class LspClient {
|
|
|
275
363
|
};
|
|
276
364
|
return map[ext ?? ""] ?? "plaintext";
|
|
277
365
|
}
|
|
366
|
+
/**
|
|
367
|
+
* Extract the actual binary name from npx args.
|
|
368
|
+
* For `npx --yes --package typescript-language-server --package typescript@5 typescript-language-server --stdio`
|
|
369
|
+
* returns `typescript-language-server` (the last non-flag arg before `--stdio`).
|
|
370
|
+
*/
|
|
371
|
+
extractBinaryFromNpxArgs(args) {
|
|
372
|
+
// Find the binary: last arg that doesn't start with -- and isn't a package version
|
|
373
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
374
|
+
const arg = args[i];
|
|
375
|
+
if (arg === "--stdio")
|
|
376
|
+
continue;
|
|
377
|
+
if (arg.startsWith("--"))
|
|
378
|
+
return null; // unexpected format
|
|
379
|
+
if (this.isPackageVersion(arg))
|
|
380
|
+
continue;
|
|
381
|
+
return arg;
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
/** Check if a string looks like a package version specifier (e.g. "typescript@5"). */
|
|
386
|
+
isPackageVersion(s) {
|
|
387
|
+
return /^[\w-]+@[\d.+*-]/.test(s);
|
|
388
|
+
}
|
|
278
389
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import { resolve } from "path";
|
|
2
|
+
import { relative, resolve } from "path";
|
|
3
3
|
import { LspClient } from "./client";
|
|
4
4
|
import { getServerForFile, DEFAULT_LSP_CONFIG } from "./config";
|
|
5
5
|
import { findProjectRoot } from "./project-root";
|
|
@@ -23,9 +23,30 @@ export class LspModule {
|
|
|
23
23
|
client;
|
|
24
24
|
failuresByServer = new Map();
|
|
25
25
|
disabledServers = new Set();
|
|
26
|
-
|
|
26
|
+
logger = null;
|
|
27
|
+
constructor(config, client, logger) {
|
|
27
28
|
this.config = { ...DEFAULT_LSP_CONFIG, ...config };
|
|
28
29
|
this.client = client ?? new LspClient();
|
|
30
|
+
this.logger = logger ?? null;
|
|
31
|
+
this.client.setLogger(this.logger);
|
|
32
|
+
}
|
|
33
|
+
/** Update the logger (e.g., after session switch). */
|
|
34
|
+
setLogger(logger) {
|
|
35
|
+
this.logger = logger;
|
|
36
|
+
this.client.setLogger(logger);
|
|
37
|
+
}
|
|
38
|
+
/** Reset disabled servers (e.g., after /lsp restart). */
|
|
39
|
+
resetDisabledServers() {
|
|
40
|
+
this.failuresByServer.clear();
|
|
41
|
+
this.disabledServers.clear();
|
|
42
|
+
}
|
|
43
|
+
/** Get list of disabled server keys. */
|
|
44
|
+
getDisabledServers() {
|
|
45
|
+
return Array.from(this.disabledServers);
|
|
46
|
+
}
|
|
47
|
+
/** Get failure counts per server. */
|
|
48
|
+
getFailureCounts() {
|
|
49
|
+
return new Map(this.failuresByServer);
|
|
29
50
|
}
|
|
30
51
|
/** Test hook: has the module disabled any LSP server for the session? */
|
|
31
52
|
isLspDisabled() {
|
|
@@ -115,6 +136,7 @@ export class LspModule {
|
|
|
115
136
|
return [
|
|
116
137
|
{
|
|
117
138
|
name: "lsp_check",
|
|
139
|
+
icon: "🩺",
|
|
118
140
|
description: 'Run static checks (LSP diagnostics) on a file or directory. For "check for errors" tasks: pass a file to check it, or a directory to check its source files (capped at 15). Returns [LSP errors] / [LSP warnings] for files with a configured LSP server.',
|
|
119
141
|
tags: ["code", "check"],
|
|
120
142
|
alwaysOn: true,
|
|
@@ -159,6 +181,7 @@ export class LspModule {
|
|
|
159
181
|
const errors = [];
|
|
160
182
|
const warnings = [];
|
|
161
183
|
const notes = [];
|
|
184
|
+
const checkedFiles = [];
|
|
162
185
|
let checked = 0;
|
|
163
186
|
for (const file of files) {
|
|
164
187
|
const serverConfig = getServerForFile(file, this.config);
|
|
@@ -172,6 +195,7 @@ export class LspModule {
|
|
|
172
195
|
const diags = await this.client.checkFile(file, ctx.baseDir, serverConfig, projectRoot);
|
|
173
196
|
this.failuresByServer.set(key, 0);
|
|
174
197
|
checked++;
|
|
198
|
+
checkedFiles.push(relative(ctx.baseDir, file).replace(/\\/g, "/"));
|
|
175
199
|
for (const d of diags) {
|
|
176
200
|
if (d.severity === 1)
|
|
177
201
|
errors.push({ file, diag: d });
|
|
@@ -197,6 +221,10 @@ export class LspModule {
|
|
|
197
221
|
}
|
|
198
222
|
}
|
|
199
223
|
const lines = [];
|
|
224
|
+
if (checked > 0) {
|
|
225
|
+
lines.push(t("lsp.checked_files", { count: String(checked) }));
|
|
226
|
+
lines.push(...checkedFiles.map((f) => ` ${f}`));
|
|
227
|
+
}
|
|
200
228
|
if (errors.length > 0) {
|
|
201
229
|
lines.push(`[LSP errors] (${errors.length}):`);
|
|
202
230
|
lines.push(formatCheckDiagnostics(errors, ctx.baseDir));
|
|
@@ -49,21 +49,31 @@ export async function probeLspServers(config, baseDir, deps = {}) {
|
|
|
49
49
|
const serverTimeout = Math.min(server.timeout ?? timeoutCap, timeoutCap);
|
|
50
50
|
const started = Date.now();
|
|
51
51
|
const probeClient = deps.client ?? new LspClient();
|
|
52
|
+
if (!deps.client)
|
|
53
|
+
probeClient.setLogger(deps.logger ?? null);
|
|
52
54
|
try {
|
|
53
55
|
await probeClient.probe(server, projectRoot, serverTimeout);
|
|
54
56
|
ok.push({ language, ok: true, durationMs: Date.now() - started });
|
|
55
57
|
}
|
|
56
58
|
catch (e) {
|
|
59
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
60
|
+
deps.logger?.warn(`LSP probe failed for ${language}: ${errorMsg}`, {
|
|
61
|
+
command: server.command,
|
|
62
|
+
timeout: serverTimeout,
|
|
63
|
+
durationMs: Date.now() - started,
|
|
64
|
+
});
|
|
57
65
|
failed.push({
|
|
58
66
|
language,
|
|
59
67
|
ok: false,
|
|
60
|
-
error:
|
|
68
|
+
error: errorMsg,
|
|
61
69
|
durationMs: Date.now() - started,
|
|
62
70
|
});
|
|
63
71
|
}
|
|
64
72
|
};
|
|
65
73
|
for (let i = 0; i < servers.length; i += maxParallel) {
|
|
66
74
|
if (Date.now() >= deadline) {
|
|
75
|
+
const remaining = servers.slice(i).map((s) => s.language);
|
|
76
|
+
deps.logger?.warn(`LSP probe deadline reached, skipping: ${remaining.join(", ")}`);
|
|
67
77
|
for (const { language } of servers.slice(i)) {
|
|
68
78
|
failed.push({ language, ok: false, error: "timeout", durationMs: 0 });
|
|
69
79
|
}
|
|
@@ -59,6 +59,8 @@ async function runCheck(config, baseDir, deps) {
|
|
|
59
59
|
return { lines: errors.slice(0, STARTUP_CHECK_ERROR_CAP) };
|
|
60
60
|
}
|
|
61
61
|
const client = deps.client ?? new LspClient();
|
|
62
|
+
if (!deps.client)
|
|
63
|
+
client.setLogger(deps.logger ?? null);
|
|
62
64
|
const files = await collectCheckFiles(baseDir, config);
|
|
63
65
|
const lines = [];
|
|
64
66
|
let checked = 0;
|
|
@@ -76,8 +78,9 @@ async function runCheck(config, baseDir, deps) {
|
|
|
76
78
|
}
|
|
77
79
|
}
|
|
78
80
|
}
|
|
79
|
-
catch {
|
|
80
|
-
// Per-file LSP failure → skip
|
|
81
|
+
catch (e) {
|
|
82
|
+
// Per-file LSP failure → log and skip (fail-open).
|
|
83
|
+
deps.logger?.debug(`Startup LSP check failed for ${file}: ${e instanceof Error ? e.message : String(e)}`);
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
if (checked === 0 || lines.length === 0)
|
|
@@ -4,6 +4,26 @@ import { resolve, extname, join } from "path";
|
|
|
4
4
|
import { platform } from "os";
|
|
5
5
|
import { findProjectRoot } from "../../lsp/project-root";
|
|
6
6
|
const TYPE_CHECK_DEBOUNCE_MS = 2000;
|
|
7
|
+
// Projects whose lint script points at a binary that is not installed
|
|
8
|
+
// (keyed by baseDir::script). The run itself is expensive and the failure
|
|
9
|
+
// marker is pure noise — skip for the rest of the process.
|
|
10
|
+
const missingLintBinaries = new Set();
|
|
11
|
+
function lintCacheKey(baseDir, lintScript) {
|
|
12
|
+
return `${baseDir}::${lintScript}`;
|
|
13
|
+
}
|
|
14
|
+
// Shell-level "command not found" across locales/runtimes: POSIX `sh` prints
|
|
15
|
+
// "command not found" (exit 127); Windows cmd.exe prints "is not recognized"
|
|
16
|
+
// (en) / "не является внутренней или внешней командой" (ru) with exit 1/9009.
|
|
17
|
+
const MISSING_BINARY_RE = /command not found|not recognized|не является внутренней|не распознан|не є внутрішньою|".+?" не является/i;
|
|
18
|
+
function isMissingBinaryError(err, combinedOutput) {
|
|
19
|
+
// 127 = POSIX sh "command not found"; 9009 = Windows cmd.exe "is not
|
|
20
|
+
// recognized" — both are authoritative regardless of output encoding.
|
|
21
|
+
if (err.status === 127 || err.status === 9009)
|
|
22
|
+
return true;
|
|
23
|
+
if (err.code === "ENOENT")
|
|
24
|
+
return true;
|
|
25
|
+
return MISSING_BINARY_RE.test(combinedOutput);
|
|
26
|
+
}
|
|
7
27
|
// Per-file syntax checks are cached by (path, content hash): re-writing a file
|
|
8
28
|
// with identical content skips the (expensive) check. Content changes → cache
|
|
9
29
|
// miss → fresh check.
|
|
@@ -16,27 +36,63 @@ function contentHash(content) {
|
|
|
16
36
|
return String(h);
|
|
17
37
|
}
|
|
18
38
|
let _winDecoder;
|
|
39
|
+
/**
|
|
40
|
+
* OEM decoder for the active console code page (CP866 on Russian Windows).
|
|
41
|
+
* Mirrors `getOemDecoder()` in modules/processes/registry.ts: a transient
|
|
42
|
+
* chcp.com failure under load must NOT be cached — only a successful probe
|
|
43
|
+
* (or the hard ibm866 fallback) is memoized.
|
|
44
|
+
*/
|
|
19
45
|
function getWinDecoder() {
|
|
20
|
-
if (_winDecoder
|
|
21
|
-
|
|
22
|
-
|
|
46
|
+
if (_winDecoder !== undefined)
|
|
47
|
+
return _winDecoder;
|
|
48
|
+
if (platform() !== "win32") {
|
|
49
|
+
_winDecoder = new TextDecoder("utf-8");
|
|
50
|
+
return _winDecoder;
|
|
51
|
+
}
|
|
52
|
+
let decoder = null;
|
|
53
|
+
try {
|
|
54
|
+
const cpOut = execSync("chcp.com", {
|
|
55
|
+
encoding: "buffer",
|
|
56
|
+
timeout: 2000,
|
|
57
|
+
windowsHide: true,
|
|
58
|
+
}).toString("latin1");
|
|
59
|
+
const m = cpOut.match(/(\d+)/);
|
|
60
|
+
if (m)
|
|
61
|
+
decoder = new TextDecoder("ibm" + m[1]);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
/* chcp failed (load/ENOENT) — retry on the next call */
|
|
65
|
+
}
|
|
66
|
+
if (!decoder) {
|
|
67
|
+
try {
|
|
68
|
+
decoder = new TextDecoder("ibm866");
|
|
23
69
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const cpOut = execSync("chcp.com", {
|
|
27
|
-
encoding: "buffer",
|
|
28
|
-
timeout: 2000,
|
|
29
|
-
windowsHide: true,
|
|
30
|
-
}).toString("latin1");
|
|
31
|
-
const m = cpOut.match(/(\d+)/);
|
|
32
|
-
_winDecoder = m ? new TextDecoder("ibm" + m[1]) : null;
|
|
33
|
-
}
|
|
34
|
-
catch {
|
|
35
|
-
_winDecoder = null;
|
|
36
|
-
}
|
|
70
|
+
catch {
|
|
71
|
+
return new TextDecoder("utf-8"); // unknown encoding — do not cache
|
|
37
72
|
}
|
|
38
73
|
}
|
|
39
|
-
|
|
74
|
+
_winDecoder = decoder;
|
|
75
|
+
return _winDecoder;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Decode child-process output to UTF-8. Modern tools (bun, node, npm, tsc)
|
|
79
|
+
* write UTF-8 into the pipe; cmd.exe builtins/errors use the OEM code page.
|
|
80
|
+
* Strict UTF-8 first; on invalid byte sequences fall back to OEM.
|
|
81
|
+
*/
|
|
82
|
+
function decodeOutput(chunks) {
|
|
83
|
+
const raw = Buffer.concat(chunks);
|
|
84
|
+
try {
|
|
85
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(raw);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
/* not valid UTF-8 → OEM code page output */
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return getWinDecoder().decode(raw);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return raw.toString("latin1");
|
|
95
|
+
}
|
|
40
96
|
}
|
|
41
97
|
export class LintOnWritePlugin {
|
|
42
98
|
name = "lint-on-write";
|
|
@@ -57,16 +113,23 @@ export class LintOnWritePlugin {
|
|
|
57
113
|
const fullPath = resolve(ctx.baseDir, path);
|
|
58
114
|
if (!existsSync(fullPath))
|
|
59
115
|
return;
|
|
116
|
+
const signal = ctx.signal;
|
|
117
|
+
if (signal?.aborted)
|
|
118
|
+
return;
|
|
60
119
|
const ext = extname(fullPath);
|
|
61
|
-
const syntaxError = await this.checkSyntax(fullPath, ext, ctx.baseDir);
|
|
120
|
+
const syntaxError = await this.checkSyntax(fullPath, ext, ctx.baseDir, signal);
|
|
121
|
+
if (signal?.aborted)
|
|
122
|
+
return;
|
|
62
123
|
if (syntaxError) {
|
|
63
124
|
result.output += `\n\n[Syntax check failed]: ${syntaxError}`;
|
|
64
125
|
return;
|
|
65
126
|
}
|
|
66
|
-
await this.runProjectLint(ctx, result);
|
|
67
|
-
|
|
127
|
+
await this.runProjectLint(ctx, result, signal);
|
|
128
|
+
if (signal?.aborted)
|
|
129
|
+
return;
|
|
130
|
+
await this.runProjectTypeCheck(fullPath, ctx.baseDir, result, signal);
|
|
68
131
|
}
|
|
69
|
-
async checkSyntax(filePath, ext, baseDir) {
|
|
132
|
+
async checkSyntax(filePath, ext, baseDir, signal) {
|
|
70
133
|
if (ext === ".ts" || ext === ".tsx" || ext === ".cts" || ext === ".mts") {
|
|
71
134
|
// Syntax-only check via bun's parser (~200ms) instead of `npx tsc`
|
|
72
135
|
// (~3-5s per write). Full type errors are still surfaced by the
|
|
@@ -84,7 +147,10 @@ export class LintOnWritePlugin {
|
|
|
84
147
|
return cached.error;
|
|
85
148
|
}
|
|
86
149
|
try {
|
|
87
|
-
await runAsync(`bun build --no-bundle --target=bun "${filePath}"`, baseDir, 10000);
|
|
150
|
+
await runAsync(`bun build --no-bundle --target=bun "${filePath}"`, baseDir, 10000, signal);
|
|
151
|
+
// Aborted check (interrupt) never validated the file — don't cache it.
|
|
152
|
+
if (signal?.aborted)
|
|
153
|
+
return null;
|
|
88
154
|
syntaxCache.set(filePath, { hash, error: null });
|
|
89
155
|
return null;
|
|
90
156
|
}
|
|
@@ -102,7 +168,7 @@ export class LintOnWritePlugin {
|
|
|
102
168
|
}
|
|
103
169
|
if (ext === ".js" || ext === ".jsx" || ext === ".cjs" || ext === ".mjs") {
|
|
104
170
|
try {
|
|
105
|
-
await runAsync(`node --check "${filePath}"`, baseDir, 5000);
|
|
171
|
+
await runAsync(`node --check "${filePath}"`, baseDir, 5000, signal);
|
|
106
172
|
return null;
|
|
107
173
|
}
|
|
108
174
|
catch (err) {
|
|
@@ -113,30 +179,50 @@ export class LintOnWritePlugin {
|
|
|
113
179
|
}
|
|
114
180
|
return null;
|
|
115
181
|
}
|
|
116
|
-
async runProjectLint(ctx, result) {
|
|
182
|
+
async runProjectLint(ctx, result, signal) {
|
|
183
|
+
let lintScript;
|
|
117
184
|
try {
|
|
118
185
|
const packageJsonPath = join(ctx.baseDir, "package.json");
|
|
119
186
|
if (!existsSync(packageJsonPath)) {
|
|
120
187
|
return;
|
|
121
188
|
}
|
|
122
189
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
123
|
-
|
|
190
|
+
lintScript = packageJson.scripts?.lint;
|
|
124
191
|
if (!lintScript) {
|
|
125
192
|
return;
|
|
126
193
|
}
|
|
194
|
+
// A declared-but-missing linter binary ("eslint" not installed) is a
|
|
195
|
+
// project-setup gap, not a code problem — appending it to every write
|
|
196
|
+
// result polluted the context ~25×/session (ses_mt4fn58c). Detect it
|
|
197
|
+
// once and skip silently for the rest of the process.
|
|
198
|
+
if (missingLintBinaries.has(lintCacheKey(ctx.baseDir, lintScript))) {
|
|
199
|
+
ctx.logger.debug(`Lint skipped (linter for "${lintScript}" not installed)`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
127
202
|
ctx.logger.debug(`Running lint: ${lintScript}`);
|
|
128
|
-
await runAsync(lintScript, ctx.baseDir, 30_000);
|
|
203
|
+
await runAsync(lintScript, ctx.baseDir, 30_000, signal);
|
|
129
204
|
ctx.logger.debug("Lint passed");
|
|
130
205
|
}
|
|
131
206
|
catch (err) {
|
|
132
207
|
const stderr = (err.stderr?.toString?.() || "").trim();
|
|
133
208
|
const stdout = (err.stdout?.toString?.() || "").trim();
|
|
134
|
-
const
|
|
209
|
+
const combined = `${stderr}\n${stdout}`;
|
|
210
|
+
if (lintScript && isMissingBinaryError(err, combined)) {
|
|
211
|
+
missingLintBinaries.add(lintCacheKey(ctx.baseDir, lintScript));
|
|
212
|
+
ctx.logger.debug(`Lint binary missing — skipping project lint for this session`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const detail = combined
|
|
216
|
+
.trim()
|
|
217
|
+
.split("\n")
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
.slice(-5)
|
|
220
|
+
.join("\n");
|
|
135
221
|
ctx.logger.warn(`Lint failed: ${err.message}`);
|
|
136
222
|
result.output += `\n\n[Project lint failed]: ${detail}`;
|
|
137
223
|
}
|
|
138
224
|
}
|
|
139
|
-
async runProjectTypeCheck(filePath, baseDir, result) {
|
|
225
|
+
async runProjectTypeCheck(filePath, baseDir, result, signal) {
|
|
140
226
|
// Resolve the project root from the EDITED FILE, not the agent baseDir.
|
|
141
227
|
// In a nested layout (`baseDir/proj/tsconfig.json`) the old baseDir-based
|
|
142
228
|
// lookup silently skipped the typecheck on every write — the whole
|
|
@@ -155,15 +241,15 @@ export class LintOnWritePlugin {
|
|
|
155
241
|
return;
|
|
156
242
|
}
|
|
157
243
|
this._checkTimestamp = now;
|
|
158
|
-
this._checkPromise = this.runTscCheck(projectRoot);
|
|
244
|
+
this._checkPromise = this.runTscCheck(projectRoot, signal);
|
|
159
245
|
const error = await this._checkPromise;
|
|
160
246
|
if (error) {
|
|
161
247
|
result.output += `\n\n[Project typecheck failed]: ${error}`;
|
|
162
248
|
}
|
|
163
249
|
}
|
|
164
|
-
async runTscCheck(baseDir) {
|
|
250
|
+
async runTscCheck(baseDir, signal) {
|
|
165
251
|
try {
|
|
166
|
-
await runAsync(`npx tsc --noEmit --skipLibCheck`, baseDir, 30000);
|
|
252
|
+
await runAsync(`npx tsc --noEmit --skipLibCheck`, baseDir, 30000, signal);
|
|
167
253
|
return null;
|
|
168
254
|
}
|
|
169
255
|
catch (err) {
|
|
@@ -186,7 +272,7 @@ export class LintOnWritePlugin {
|
|
|
186
272
|
* Run a command asynchronously (non-blocking event loop) and resolve when it
|
|
187
273
|
* exits. Rejects with captured stderr/stdout on non-zero exit or timeout.
|
|
188
274
|
*/
|
|
189
|
-
function runAsync(command, cwd, timeoutMs) {
|
|
275
|
+
function runAsync(command, cwd, timeoutMs, signal) {
|
|
190
276
|
return new Promise((resolve, reject) => {
|
|
191
277
|
const child = spawn(command, {
|
|
192
278
|
cwd,
|
|
@@ -194,28 +280,45 @@ function runAsync(command, cwd, timeoutMs) {
|
|
|
194
280
|
windowsHide: true,
|
|
195
281
|
stdio: ["ignore", "pipe", "pipe"],
|
|
196
282
|
});
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const decoder = getWinDecoder();
|
|
283
|
+
const utf8Chunks = [];
|
|
284
|
+
const oemChunks = [];
|
|
200
285
|
child.stdout?.on("data", (d) => {
|
|
201
|
-
|
|
286
|
+
utf8Chunks.push(Buffer.from(d));
|
|
202
287
|
});
|
|
203
288
|
child.stderr?.on("data", (d) => {
|
|
204
|
-
|
|
289
|
+
oemChunks.push(Buffer.from(d));
|
|
205
290
|
});
|
|
206
291
|
const timer = setTimeout(() => {
|
|
207
292
|
child.kill();
|
|
208
293
|
reject(new Error(`Command timed out after ${timeoutMs}ms`));
|
|
209
294
|
}, timeoutMs);
|
|
295
|
+
// Interrupt (Esc/Ctrl+C): kill the child so the agent loop can return to
|
|
296
|
+
// the prompt immediately instead of waiting out a long tsc/lint run. The
|
|
297
|
+
// close handler resolves empty when aborted — the caller's `signal.aborted`
|
|
298
|
+
// guard discards the result anyway.
|
|
299
|
+
const onAbort = () => child.kill();
|
|
300
|
+
if (signal?.aborted) {
|
|
301
|
+
child.kill();
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
305
|
+
}
|
|
210
306
|
child.on("error", (err) => {
|
|
211
307
|
clearTimeout(timer);
|
|
212
|
-
|
|
308
|
+
signal?.removeEventListener("abort", onAbort);
|
|
309
|
+
if (signal?.aborted) {
|
|
310
|
+
resolve({ stdout: decodeOutput(utf8Chunks), stderr: decodeOutput(oemChunks) });
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
reject(err);
|
|
314
|
+
}
|
|
213
315
|
});
|
|
214
316
|
child.on("close", (code) => {
|
|
215
317
|
clearTimeout(timer);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
318
|
+
signal?.removeEventListener("abort", onAbort);
|
|
319
|
+
const stdout = decodeOutput(utf8Chunks);
|
|
320
|
+
const stderr = decodeOutput(oemChunks);
|
|
321
|
+
if (signal?.aborted || code === 0) {
|
|
219
322
|
resolve({ stdout, stderr });
|
|
220
323
|
}
|
|
221
324
|
else {
|