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.
Files changed (107) hide show
  1. package/dist/cli/commands.js +162 -38
  2. package/dist/cli/completer.js +5 -5
  3. package/dist/cli/main.js +42 -54
  4. package/dist/cli/repl-commands.js +138 -38
  5. package/dist/cli/repl.js +175 -89
  6. package/dist/cli/run-result.js +11 -0
  7. package/dist/cli/security-commands.js +6 -6
  8. package/dist/cli/setup.js +21 -15
  9. package/dist/config/config.js +54 -27
  10. package/dist/config/defaults.js +17 -0
  11. package/dist/config/domains.js +179 -0
  12. package/dist/config/index.js +2 -1
  13. package/dist/config/security.js +28 -8
  14. package/dist/core/agent.js +162 -30
  15. package/dist/core/bootstrap.js +94 -17
  16. package/dist/core/crash-handler.js +51 -0
  17. package/dist/core/environment.js +199 -0
  18. package/dist/core/session-logger.js +60 -6
  19. package/dist/core/version.js +2 -0
  20. package/dist/i18n/en.json +120 -39
  21. package/dist/i18n/ru.json +91 -10
  22. package/dist/llm/openai-compat.js +191 -53
  23. package/dist/llm/orchestrator.js +5 -3
  24. package/dist/logger/app-logger.js +50 -4
  25. package/dist/main.js +1288 -635
  26. package/dist/modules/browser/session.js +4 -0
  27. package/dist/modules/certification/cli.js +58 -19
  28. package/dist/modules/certification/loader.js +2 -1
  29. package/dist/modules/certification/manifest.js +22 -14
  30. package/dist/modules/certification/runner.js +91 -5
  31. package/dist/modules/certification/scenarios.js +290 -7
  32. package/dist/modules/context/fact-extractor.js +6 -0
  33. package/dist/modules/context/manager.js +19 -2
  34. package/dist/modules/execution/audit-runners.js +61 -7
  35. package/dist/modules/execution/execution-plugin.js +219 -60
  36. package/dist/modules/execution/module.js +207 -18
  37. package/dist/modules/execution/moe-executor.js +33 -20
  38. package/dist/modules/execution/plan-store.js +39 -0
  39. package/dist/modules/execution/plan-tool.js +188 -19
  40. package/dist/modules/execution/planner.js +27 -23
  41. package/dist/modules/execution/stuck-detector.js +244 -8
  42. package/dist/modules/execution/tracker.js +8 -6
  43. package/dist/modules/execution/verifier.js +15 -2
  44. package/dist/modules/hallucination/detector.js +4 -0
  45. package/dist/modules/hallucination/factual.js +45 -5
  46. package/dist/modules/indexer/module.js +1 -0
  47. package/dist/modules/lsp/client.js +123 -12
  48. package/dist/modules/lsp/index.js +1 -1
  49. package/dist/modules/lsp/module.js +30 -2
  50. package/dist/modules/lsp/probe.js +11 -1
  51. package/dist/modules/lsp/startup-check.js +5 -2
  52. package/dist/modules/plugins/builtin/lint-on-write.js +144 -41
  53. package/dist/modules/plugins/manager.js +57 -13
  54. package/dist/modules/pricing/index.js +61 -0
  55. package/dist/modules/pricing/prices.js +129 -0
  56. package/dist/modules/providers/create.js +22 -0
  57. package/dist/modules/providers/fallback.js +79 -0
  58. package/dist/modules/providers/health.js +46 -0
  59. package/dist/modules/providers/index.js +5 -0
  60. package/dist/modules/providers/manager.js +161 -0
  61. package/dist/modules/providers/presets.js +128 -0
  62. package/dist/modules/providers/registry.js +22 -0
  63. package/dist/modules/providers/types.js +1 -0
  64. package/dist/modules/registry.js +1 -0
  65. package/dist/modules/security/command-validator.js +14 -0
  66. package/dist/modules/security/encryption.js +6 -6
  67. package/dist/modules/security/network-validator.js +17 -0
  68. package/dist/modules/security/path-validator.js +22 -26
  69. package/dist/modules/session/store.js +10 -10
  70. package/dist/tools/approve.js +1 -0
  71. package/dist/tools/attach-image.js +12 -0
  72. package/dist/tools/bash.js +27 -4
  73. package/dist/tools/browser.js +1 -0
  74. package/dist/tools/chunk-query.js +1 -0
  75. package/dist/tools/create-dir.js +1 -0
  76. package/dist/tools/delete-file.js +1 -0
  77. package/dist/tools/download-file.js +1 -0
  78. package/dist/tools/edit-file.js +2 -1
  79. package/dist/tools/enable-tools.js +1 -0
  80. package/dist/tools/executor.js +17 -7
  81. package/dist/tools/file-info.js +1 -0
  82. package/dist/tools/glob-tool.js +1 -0
  83. package/dist/tools/grep-tool.js +54 -13
  84. package/dist/tools/list-dir.js +1 -0
  85. package/dist/tools/load-skill.js +1 -0
  86. package/dist/tools/mcp-call.js +1 -0
  87. package/dist/tools/move-file.js +1 -0
  88. package/dist/tools/path-utils.js +51 -1
  89. package/dist/tools/pipeline-run.js +1 -0
  90. package/dist/tools/process-kill.js +11 -0
  91. package/dist/tools/process-list.js +1 -0
  92. package/dist/tools/process-log.js +9 -0
  93. package/dist/tools/question.js +1 -0
  94. package/dist/tools/read-file.js +94 -6
  95. package/dist/tools/recall.js +1 -0
  96. package/dist/tools/remember.js +1 -0
  97. package/dist/tools/scope-check.js +7 -5
  98. package/dist/tools/search-history.js +1 -0
  99. package/dist/tools/subagent.js +4 -4
  100. package/dist/tools/web-browse.js +1 -0
  101. package/dist/tools/web-fetch.js +27 -6
  102. package/dist/tools/web-search.js +70 -43
  103. package/dist/tools/write-file.js +1 -0
  104. package/dist/ui/line-editor.js +142 -23
  105. package/dist/ui/line-math.js +8 -4
  106. package/dist/ui/renderer.js +57 -7
  107. 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
- await this.sendRequest("initialize", initParams, timeout);
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 = config.args ?? [];
162
+ const args = effectiveArgs;
98
163
  const isWin = platform() === "win32";
99
- let spawnCommand = resolveSpawnCommand(config.command);
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
- reject(new Error("LSP server start timeout"));
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
- reject(new Error(`LSP request timeout: ${method}`));
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
- this.sendRequest("shutdown", null, 3000).catch(() => { });
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,3 +1,3 @@
1
1
  export { LspModule } from "./module";
2
- export { LspClient } from "./client";
2
+ export { LspClient, FileOnlyLogger } from "./client";
3
3
  export { getServerForFile, DEFAULT_LSP_CONFIG } from "./config";
@@ -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
- constructor(config, client) {
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: e instanceof Error ? e.message : String(e),
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 silently (fail-open).
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 === undefined) {
21
- if (platform() !== "win32") {
22
- _winDecoder = null;
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
- else {
25
- try {
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
- return _winDecoder ?? new TextDecoder("utf-8");
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
- await this.runProjectTypeCheck(fullPath, ctx.baseDir, result);
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
- const lintScript = packageJson.scripts?.lint;
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 detail = (stderr || stdout || err.message).split("\n").filter(Boolean).slice(-5).join("\n");
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
- let stdout = "";
198
- let stderr = "";
199
- const decoder = getWinDecoder();
283
+ const utf8Chunks = [];
284
+ const oemChunks = [];
200
285
  child.stdout?.on("data", (d) => {
201
- stdout += decoder.decode(d, { stream: true });
286
+ utf8Chunks.push(Buffer.from(d));
202
287
  });
203
288
  child.stderr?.on("data", (d) => {
204
- stderr += decoder.decode(d, { stream: true });
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
- reject(err);
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
- stdout += decoder.decode();
217
- stderr += decoder.decode();
218
- if (code === 0) {
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 {