micro-models-agent 0.28.17 → 0.29.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 (94) hide show
  1. package/dist/cli/commands.js +3 -116
  2. package/dist/cli/main.js +8 -35
  3. package/dist/cli/repl.js +611 -110
  4. package/dist/cli/setup.js +12 -32
  5. package/dist/config/config.js +30 -46
  6. package/dist/config/defaults.js +1 -10
  7. package/dist/config/security.js +8 -15
  8. package/dist/core/agent-moe.js +12 -24
  9. package/dist/core/agent.js +47 -281
  10. package/dist/core/bootstrap.js +36 -52
  11. package/dist/core/session-logger.js +2 -35
  12. package/dist/i18n/en.json +15 -79
  13. package/dist/i18n/index.js +9 -12
  14. package/dist/i18n/ru.json +15 -79
  15. package/dist/index.js +13 -13
  16. package/dist/llm/openai-compat.js +10 -39
  17. package/dist/logger/app-logger.js +16 -83
  18. package/dist/main.js +624 -243
  19. package/dist/modules/browser/session.js +60 -108
  20. package/dist/modules/context/history.js +15 -0
  21. package/dist/modules/context/manager.js +10 -119
  22. package/dist/modules/execution/auditor.js +39 -33
  23. package/dist/modules/execution/index.js +6 -8
  24. package/dist/modules/execution/module.js +32 -474
  25. package/dist/modules/execution/moe-executor.js +40 -97
  26. package/dist/modules/execution/planner.js +13 -63
  27. package/dist/modules/execution/stuck-detector.js +39 -252
  28. package/dist/modules/execution/tracker.js +7 -21
  29. package/dist/modules/execution/verifier.js +17 -46
  30. package/dist/modules/hallucination/confidence.js +2 -7
  31. package/dist/modules/hallucination/consistency.js +42 -8
  32. package/dist/modules/hallucination/detector.js +21 -26
  33. package/dist/modules/hallucination/factual.js +150 -170
  34. package/dist/modules/hallucination/index.js +4 -5
  35. package/dist/modules/index.js +5 -5
  36. package/dist/modules/mcp/client.js +2 -8
  37. package/dist/modules/memory/store.js +0 -4
  38. package/dist/modules/plugins/builtin/lint-on-write.js +38 -143
  39. package/dist/modules/processes/detect.js +34 -0
  40. package/dist/modules/processes/index.js +2 -1
  41. package/dist/modules/processes/registry.js +35 -125
  42. package/dist/modules/processes/runner.js +110 -9
  43. package/dist/modules/security/audit-log.js +10 -30
  44. package/dist/modules/security/command-validator.js +16 -42
  45. package/dist/modules/security/content-scanner.js +8 -9
  46. package/dist/modules/security/network-validator.js +2 -2
  47. package/dist/modules/security/path-validator.js +10 -64
  48. package/dist/modules/security/security-policies.js +67 -221
  49. package/dist/modules/security/session-encryption.js +25 -42
  50. package/dist/modules/session/manager.js +10 -15
  51. package/dist/modules/session/store.js +8 -62
  52. package/dist/modules/skills/index.js +3 -2
  53. package/dist/modules/skills/matcher.js +27 -0
  54. package/dist/modules/skills/module.js +23 -10
  55. package/dist/tools/bash.js +90 -287
  56. package/dist/tools/create-dir.js +1 -0
  57. package/dist/tools/delete-file.js +1 -0
  58. package/dist/tools/edit-file.js +8 -10
  59. package/dist/tools/executor.js +7 -57
  60. package/dist/tools/grep-tool.js +29 -51
  61. package/dist/tools/index.js +40 -55
  62. package/dist/tools/load-skill.js +18 -14
  63. package/dist/tools/move-file.js +2 -3
  64. package/dist/tools/pipeline-run.js +1 -1
  65. package/dist/tools/read-file.js +5 -15
  66. package/dist/tools/search-history.js +22 -42
  67. package/dist/tools/subagent.js +12 -21
  68. package/dist/tools/web-browse.js +25 -54
  69. package/dist/tools/web-fetch.js +34 -60
  70. package/dist/tools/web-search.js +20 -39
  71. package/dist/tools/write-file.js +10 -13
  72. package/dist/ui/diff.js +16 -9
  73. package/dist/ui/renderer.js +6 -69
  74. package/package.json +1 -1
  75. package/dist/cli/repl-commands.js +0 -633
  76. package/dist/core/workspace.js +0 -76
  77. package/dist/logger/file-log.js +0 -151
  78. package/dist/modules/certification/cli.js +0 -176
  79. package/dist/modules/certification/fact-checker.js +0 -84
  80. package/dist/modules/certification/loader.js +0 -111
  81. package/dist/modules/certification/manifest.js +0 -50
  82. package/dist/modules/certification/runner.js +0 -162
  83. package/dist/modules/certification/scenarios.js +0 -124
  84. package/dist/modules/certification/types.js +0 -1
  85. package/dist/modules/execution/plan-coverage.js +0 -68
  86. package/dist/modules/execution/plan-persister.js +0 -46
  87. package/dist/modules/execution/plan-store.js +0 -159
  88. package/dist/modules/hallucination/js-identifiers.js +0 -72
  89. package/dist/modules/hallucination/llm-judge.js +0 -103
  90. package/dist/modules/lsp/client.js +0 -235
  91. package/dist/modules/lsp/config.js +0 -81
  92. package/dist/modules/lsp/index.js +0 -3
  93. package/dist/modules/lsp/module.js +0 -68
  94. package/dist/modules/lsp/types.js +0 -1
@@ -1,62 +1,27 @@
1
- import { spawn, execSync } from "child_process";
2
- import { existsSync, readFileSync } from "fs";
3
- import { resolve, extname, join } from "path";
4
- import { platform } from "os";
1
+ import { execSync } from 'child_process';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { resolve, extname, join } from 'path';
4
+ let projectTypeCheckPromise = null;
5
+ let projectTypeCheckTimestamp = 0;
5
6
  const TYPE_CHECK_DEBOUNCE_MS = 2000;
6
- // Per-file syntax checks are cached by (path, content hash): re-writing a file
7
- // with identical content skips the (expensive) check. Content changes → cache
8
- // miss → fresh check.
9
- const syntaxCache = new Map();
10
- function contentHash(content) {
11
- let h = 5381;
12
- for (let i = 0; i < content.length; i++) {
13
- h = ((h << 5) + h + content.charCodeAt(i)) | 0;
14
- }
15
- return String(h);
16
- }
17
- let _winDecoder;
18
- function getWinDecoder() {
19
- if (_winDecoder === undefined) {
20
- if (platform() !== "win32") {
21
- _winDecoder = null;
22
- }
23
- else {
24
- try {
25
- const cpOut = execSync("chcp.com", {
26
- encoding: "buffer",
27
- timeout: 2000,
28
- windowsHide: true,
29
- }).toString("latin1");
30
- const m = cpOut.match(/(\d+)/);
31
- _winDecoder = m ? new TextDecoder("ibm" + m[1]) : null;
32
- }
33
- catch {
34
- _winDecoder = null;
35
- }
36
- }
37
- }
38
- return _winDecoder ?? new TextDecoder("utf-8");
39
- }
40
7
  export class LintOnWritePlugin {
41
- name = "lint-on-write";
8
+ name = 'lint-on-write';
42
9
  priority = 10;
43
- _checkPromise = null;
44
- _checkTimestamp = 0;
45
10
  async onAfterTool(ctx, call, result) {
46
- if (call.name !== "write_file" && call.name !== "edit_file") {
11
+ if (call.name !== 'write_file' && call.name !== 'edit_file') {
47
12
  return;
48
13
  }
49
14
  if (!result.success) {
50
15
  return;
51
16
  }
52
- const path = String(call.arguments.path || "");
17
+ const path = String(call.arguments.path || '');
53
18
  if (!path)
54
19
  return;
55
20
  const fullPath = resolve(ctx.baseDir, path);
56
21
  if (!existsSync(fullPath))
57
22
  return;
58
23
  const ext = extname(fullPath);
59
- const syntaxError = await this.checkSyntax(fullPath, ext, ctx.baseDir);
24
+ const syntaxError = this.checkSyntax(fullPath, ext, ctx.baseDir);
60
25
  if (syntaxError) {
61
26
  result.output += `\n\n[Syntax check failed]: ${syntaxError}`;
62
27
  return;
@@ -64,50 +29,32 @@ export class LintOnWritePlugin {
64
29
  await this.runProjectLint(ctx, result);
65
30
  await this.runProjectTypeCheck(ctx, result);
66
31
  }
67
- async checkSyntax(filePath, ext, baseDir) {
68
- if (ext === ".ts" || ext === ".tsx") {
69
- // Syntax-only check via bun's parser (~200ms) instead of `npx tsc`
70
- // (~3-5s per write). Full type errors are still surfaced by the
71
- // debounced project typecheck below when a tsconfig exists.
72
- let content = "";
73
- try {
74
- content = readFileSync(filePath, "utf-8");
75
- }
76
- catch {
77
- return null;
78
- }
79
- const hash = contentHash(content);
80
- const cached = syntaxCache.get(filePath);
81
- if (cached && cached.hash === hash) {
82
- return cached.error;
83
- }
32
+ checkSyntax(filePath, ext, baseDir) {
33
+ if (ext === '.ts' || ext === '.tsx') {
84
34
  try {
85
- await runAsync(`bun build --no-bundle --target=bun "${filePath}"`, baseDir, 10000);
86
- syntaxCache.set(filePath, { hash, error: null });
35
+ execSync(`npx tsc --noEmit --skipLibCheck "${filePath}"`, { cwd: baseDir, stdio: 'pipe', timeout: 10000 });
87
36
  return null;
88
37
  }
89
38
  catch (err) {
90
- if (err.status === 127 ||
91
- err.message.includes("not found") ||
92
- err.message.includes("ENOENT")) {
39
+ if (err.status === 127 || err.message.includes('not found') || err.message.includes('ENOENT')) {
93
40
  return null;
94
41
  }
95
- const stderr = err.stderr?.toString() || err.stdout?.toString() || "";
96
- const firstError = stderr.split("\n").find((line) => line.trim()) ||
97
- "TypeScript syntax error";
98
- syntaxCache.set(filePath, { hash, error: firstError.trim() });
99
- return firstError.trim();
42
+ const stderr = err.stderr?.toString() || err.stdout?.toString() || '';
43
+ if (stderr.includes('error TS')) {
44
+ const firstError = stderr.split('\n').find((line) => line.includes('error TS')) || 'TypeScript syntax error';
45
+ return firstError.trim();
46
+ }
47
+ return null;
100
48
  }
101
49
  }
102
- if (ext === ".js" || ext === ".jsx") {
50
+ if (ext === '.js' || ext === '.jsx') {
103
51
  try {
104
- await runAsync(`node --check "${filePath}"`, baseDir, 5000);
52
+ execSync(`node --check "${filePath}"`, { cwd: baseDir, stdio: 'pipe', timeout: 5000 });
105
53
  return null;
106
54
  }
107
55
  catch (err) {
108
- const stderr = err.stderr?.toString() || "";
109
- const firstError = stderr.split("\n").find((line) => line.trim()) ||
110
- "JavaScript syntax error";
56
+ const stderr = err.stderr?.toString() || '';
57
+ const firstError = stderr.split('\n').find((line) => line.trim()) || 'JavaScript syntax error';
111
58
  return firstError.trim();
112
59
  }
113
60
  }
@@ -115,18 +62,18 @@ export class LintOnWritePlugin {
115
62
  }
116
63
  async runProjectLint(ctx, result) {
117
64
  try {
118
- const packageJsonPath = join(ctx.baseDir, "package.json");
65
+ const packageJsonPath = join(ctx.baseDir, 'package.json');
119
66
  if (!existsSync(packageJsonPath)) {
120
67
  return;
121
68
  }
122
- const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
69
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
123
70
  const lintScript = packageJson.scripts?.lint;
124
71
  if (!lintScript) {
125
72
  return;
126
73
  }
127
74
  ctx.logger.debug(`Running lint: ${lintScript}`);
128
- await runAsync(lintScript, ctx.baseDir, 30_000);
129
- ctx.logger.debug("Lint passed");
75
+ execSync(lintScript, { cwd: ctx.baseDir, stdio: 'pipe' });
76
+ ctx.logger.debug('Lint passed');
130
77
  }
131
78
  catch (err) {
132
79
  ctx.logger.warn(`Lint failed: ${err.message}`);
@@ -134,93 +81,41 @@ export class LintOnWritePlugin {
134
81
  }
135
82
  }
136
83
  async runProjectTypeCheck(ctx, result) {
137
- const tsconfigPath = join(ctx.baseDir, "tsconfig.json");
84
+ const tsconfigPath = join(ctx.baseDir, 'tsconfig.json');
138
85
  if (!existsSync(tsconfigPath)) {
139
86
  return;
140
87
  }
141
88
  const now = Date.now();
142
- if (this._checkPromise &&
143
- now - this._checkTimestamp < TYPE_CHECK_DEBOUNCE_MS) {
144
- const error = await this._checkPromise;
89
+ if (projectTypeCheckPromise && now - projectTypeCheckTimestamp < TYPE_CHECK_DEBOUNCE_MS) {
90
+ const error = await projectTypeCheckPromise;
145
91
  if (error) {
146
92
  result.output += `\n\n[Project typecheck failed]: ${error}`;
147
93
  }
148
94
  return;
149
95
  }
150
- this._checkTimestamp = now;
151
- this._checkPromise = this.runTscCheck(ctx.baseDir);
152
- const error = await this._checkPromise;
96
+ projectTypeCheckTimestamp = now;
97
+ projectTypeCheckPromise = this.runTscCheck(ctx.baseDir);
98
+ const error = await projectTypeCheckPromise;
153
99
  if (error) {
154
100
  result.output += `\n\n[Project typecheck failed]: ${error}`;
155
101
  }
156
102
  }
157
103
  async runTscCheck(baseDir) {
158
104
  try {
159
- await runAsync(`npx tsc --noEmit --skipLibCheck`, baseDir, 30000);
105
+ execSync(`npx tsc --noEmit --skipLibCheck`, { cwd: baseDir, stdio: 'pipe', timeout: 30000 });
160
106
  return null;
161
107
  }
162
108
  catch (err) {
163
- if (err.status === 127 ||
164
- err.message.includes("not found") ||
165
- err.message.includes("ENOENT")) {
109
+ if (err.status === 127 || err.message.includes('not found') || err.message.includes('ENOENT')) {
166
110
  return null;
167
111
  }
168
- const stderr = err.stderr?.toString() || err.stdout?.toString() || "";
169
- if (stderr.includes("error TS")) {
170
- const firstError = stderr
171
- .split("\n")
172
- .find((line) => line.includes("error TS")) ||
173
- "TypeScript type error";
112
+ const stderr = err.stderr?.toString() || err.stdout?.toString() || '';
113
+ if (stderr.includes('error TS')) {
114
+ const firstError = stderr.split('\n').find((line) => line.includes('error TS')) || 'TypeScript type error';
174
115
  return firstError.trim();
175
116
  }
176
117
  return null;
177
118
  }
178
119
  }
179
120
  }
180
- /**
181
- * Run a command asynchronously (non-blocking event loop) and resolve when it
182
- * exits. Rejects with captured stderr/stdout on non-zero exit or timeout.
183
- */
184
- function runAsync(command, cwd, timeoutMs) {
185
- return new Promise((resolve, reject) => {
186
- const child = spawn(command, {
187
- cwd,
188
- shell: true,
189
- windowsHide: true,
190
- stdio: ["ignore", "pipe", "pipe"],
191
- });
192
- let stdout = "";
193
- let stderr = "";
194
- const decoder = getWinDecoder();
195
- child.stdout?.on("data", (d) => {
196
- stdout += decoder.decode(d, { stream: true });
197
- });
198
- child.stderr?.on("data", (d) => {
199
- stderr += decoder.decode(d, { stream: true });
200
- });
201
- const timer = setTimeout(() => {
202
- child.kill();
203
- reject(new Error(`Command timed out after ${timeoutMs}ms`));
204
- }, timeoutMs);
205
- child.on("error", (err) => {
206
- clearTimeout(timer);
207
- reject(err);
208
- });
209
- child.on("close", (code) => {
210
- clearTimeout(timer);
211
- stdout += decoder.decode();
212
- stderr += decoder.decode();
213
- if (code === 0) {
214
- resolve({ stdout, stderr });
215
- }
216
- else {
217
- const err = new Error(`Command failed with exit code ${code}`);
218
- err.stdout = stdout;
219
- err.stderr = stderr;
220
- err.status = code;
221
- reject(err);
222
- }
223
- });
224
- });
225
- }
226
121
  export const plugin = new LintOnWritePlugin();
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Heuristic detection of long-running (server/watch) commands.
3
+ *
4
+ * The explicit `background: true` bash parameter always wins; this list is a
5
+ * convenience so dev servers don't block the agent. It only inspects the raw
6
+ * shell command (a technical process-type decision, not task classification).
7
+ */
8
+ const LONG_RUNNING_PATTERNS = [
9
+ /\b(npm|pnpm|yarn|bun|npx)\s+(run\s+)?(dev|start|serve|preview|watch|server)\b/i,
10
+ /\b(deno|node)\s+.*\b(run\s+)?(dev|serve|watch|server)\b/i,
11
+ /(^|\s)--watch\b/i,
12
+ /(^|\s)-w\b/i,
13
+ /\bnodemon\b/i,
14
+ /\btsx watch\b/i,
15
+ /\b(ts-node|tsc)\s+.*--watch\b/i,
16
+ /\bvite(?!\s+(build|create))\b/i,
17
+ /\bwebpack(-dev-server|\s+serve)\b/i,
18
+ /\bastro dev\b/i,
19
+ /\bnext (dev|start)\b/i,
20
+ /\bnuxt (dev|start)\b/i,
21
+ /\bsvelte-kit (dev|preview)\b/i,
22
+ /\bgatsby develop\b/i,
23
+ /\bdocker(-compose)?\s+.*\bup\b/i,
24
+ /\buvicorn\b|\bgunicorn\b/i,
25
+ /\bpython\s+-m\s+http\.server\b/i,
26
+ /\bflask run\b/i,
27
+ /\bphp artisan serve\b/i,
28
+ /\brails (server|s)\b/i,
29
+ /\bvitest watch\b/i,
30
+ /\bjest --watch\b/i,
31
+ ];
32
+ export function isLongRunningCommand(command) {
33
+ return LONG_RUNNING_PATTERNS.some((pattern) => pattern.test(command));
34
+ }
@@ -1,2 +1,3 @@
1
- export { registerKillable, unregisterKillable, killByCallId, } from "./runner";
1
+ export { runCommand, killByCallId } from "./runner";
2
2
  export { processRegistry } from "./registry";
3
+ export { isLongRunningCommand } from "./detect";
@@ -1,6 +1,6 @@
1
- import { spawn, spawnSync } from "child_process";
1
+ import { spawn } from "child_process";
2
2
  import { platform } from "os";
3
- const MAX_LOG_LINES = 2000;
3
+ const MAX_LOG_LINES = 300;
4
4
  const MAX_KEPT_PROCESSES = 20;
5
5
  let seq = 0;
6
6
  function killTree(child) {
@@ -8,16 +8,10 @@ function killTree(child) {
8
8
  if (!pid)
9
9
  return;
10
10
  if (platform() === "win32") {
11
- try {
12
- spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], {
13
- windowsHide: true,
14
- stdio: "ignore",
15
- timeout: 3000,
16
- });
17
- }
18
- catch {
19
- /* already dead */
20
- }
11
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
12
+ windowsHide: true,
13
+ stdio: "ignore",
14
+ });
21
15
  return;
22
16
  }
23
17
  try {
@@ -35,10 +29,9 @@ function killTree(child) {
35
29
  class ProcessRegistry {
36
30
  procs = new Map();
37
31
  children = new Map();
38
- exitWaiters = new Map();
39
32
  /**
40
- * Start a command. The returned entry buffers output and reports status; a
41
- * call may check `waitForExit(id)` to learn when (if ever) it finishes.
33
+ * Start a background process. Returns immediately with the entry; output is
34
+ * buffered into `entry.log` as it arrives.
42
35
  */
43
36
  start(command, cwd, sessionId) {
44
37
  const id = `proc_${Date.now()}_${++seq}`;
@@ -55,98 +48,46 @@ class ProcessRegistry {
55
48
  };
56
49
  this.trimOldEntries();
57
50
  this.procs.set(id, entry);
58
- let child;
59
- try {
60
- child = spawn(command, {
61
- cwd,
62
- shell: true,
63
- windowsHide: true,
64
- detached: platform() !== "win32",
65
- stdio: ["ignore", "pipe", "pipe"],
66
- });
67
- }
68
- catch (e) {
69
- entry.status = "killed";
70
- entry.spawnError = e?.message || String(e);
71
- entry.log.push(`[process error] ${entry.spawnError}`);
72
- return entry;
73
- }
51
+ const child = spawn(command, {
52
+ cwd,
53
+ shell: true,
54
+ windowsHide: true,
55
+ detached: platform() !== "win32",
56
+ stdio: ["ignore", "pipe", "pipe"],
57
+ });
74
58
  this.children.set(id, child);
75
59
  entry.pid = child.pid ?? 0;
76
- // On Windows, children spawned via cmd.exe output in the OEM code page,
77
- // NOT UTF-8. Detect the active code page via chcp.com; fall back to
78
- // ibm866 (Russian CP866) when detection fails — still correct for the
79
- // majority of users, and always better than the UTF-8 default.
80
- const decoder = platform() === "win32"
81
- ? (() => {
82
- try {
83
- const cpOut = require("child_process")
84
- .execSync("chcp.com", {
85
- encoding: "buffer",
86
- timeout: 2000,
87
- windowsHide: true,
88
- })
89
- .toString("latin1");
90
- const m = cpOut.match(/(\d+)/);
91
- if (m)
92
- return new TextDecoder("ibm" + m[1]);
93
- }
94
- catch {
95
- /* ignore */
96
- }
97
- return new TextDecoder("ibm866");
98
- })()
99
- : new TextDecoder("utf-8");
100
- let buf = "";
101
- const pushLine = (line) => {
102
- if (entry.log.length >= MAX_LOG_LINES) {
103
- entry.log.shift();
104
- }
105
- entry.log.push(line);
106
- };
60
+ let partial = "";
107
61
  const append = (chunk) => {
108
- const decoded = decoder.decode(chunk, { stream: true });
109
- buf += decoded;
110
- const lines = buf.split(/\r?\n/);
111
- buf = lines.pop() ?? "";
62
+ const text = partial + chunk.toString();
63
+ const lines = text.split(/\r?\n/);
64
+ partial = lines.pop() ?? "";
112
65
  for (const line of lines) {
113
- pushLine(line);
114
- }
115
- };
116
- const flush = () => {
117
- buf += decoder.decode();
118
- if (buf) {
119
- pushLine(buf);
120
- buf = "";
66
+ if (entry.log.length >= MAX_LOG_LINES) {
67
+ entry.log.shift();
68
+ }
69
+ entry.log.push(line);
121
70
  }
122
71
  };
123
- const resolveExit = () => {
124
- this.exitWaiters.get(id)?.resolve();
125
- };
126
72
  child.stdout?.on("data", append);
127
73
  child.stderr?.on("data", append);
128
74
  child.on("error", (err) => {
129
75
  if (entry.status === "running") {
130
76
  entry.status = "killed";
131
77
  }
132
- entry.spawnError = err.message;
133
78
  append(Buffer.from(`[process error] ${err.message}`));
134
- resolveExit();
135
79
  });
136
80
  child.on("close", (code) => {
137
- flush();
138
81
  if (entry.status === "running") {
139
82
  entry.status = "exited";
140
83
  }
141
84
  entry.exitCode = code;
142
85
  this.children.delete(id);
143
- resolveExit();
144
- });
145
- let resolve;
146
- const promise = new Promise((r) => {
147
- resolve = r;
86
+ if (partial) {
87
+ entry.log.push(partial);
88
+ partial = "";
89
+ }
148
90
  });
149
- this.exitWaiters.set(id, { promise, resolve });
150
91
  return entry;
151
92
  }
152
93
  list(sessionId) {
@@ -187,51 +128,20 @@ class ProcessRegistry {
187
128
  }
188
129
  return targets.length;
189
130
  }
190
- /**
191
- * Resolve `true` when the process exits (or fails to spawn) within
192
- * `timeoutMs`, otherwise `false` (still running → a background process).
193
- */
194
- waitForExit(id, timeoutMs) {
195
- const entry = this.procs.get(id);
196
- const waiter = this.exitWaiters.get(id);
197
- if (!entry || entry.status !== "running" || !waiter) {
198
- return Promise.resolve(true);
199
- }
200
- return new Promise((resolve) => {
201
- const timer = setTimeout(() => resolve(false), timeoutMs);
202
- waiter.promise.then(() => {
203
- clearTimeout(timer);
204
- resolve(true);
205
- });
206
- });
207
- }
208
- /**
209
- * Remove a finished entry (foreground results do not linger). A still-running
210
- * entry is killed first so nothing is orphaned.
211
- */
212
- remove(id) {
213
- const entry = this.procs.get(id);
214
- if (!entry)
215
- return false;
216
- if (entry.status === "running") {
217
- const child = this.children.get(id);
218
- if (child) {
219
- killTree(child);
220
- }
221
- entry.status = "killed";
222
- }
223
- this.procs.delete(id);
224
- this.children.delete(id);
225
- this.exitWaiters.delete(id);
226
- return true;
227
- }
228
131
  trimOldEntries() {
229
132
  if (this.procs.size < MAX_KEPT_PROCESSES)
230
133
  return;
231
134
  const sorted = Array.from(this.procs.values()).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
232
135
  const toRemove = sorted.slice(0, sorted.length - MAX_KEPT_PROCESSES + 1);
233
136
  for (const entry of toRemove) {
234
- this.remove(entry.id);
137
+ if (entry.status === "running") {
138
+ const child = this.children.get(entry.id);
139
+ if (child) {
140
+ killTree(child);
141
+ }
142
+ }
143
+ this.procs.delete(entry.id);
144
+ this.children.delete(entry.id);
235
145
  }
236
146
  }
237
147
  }
@@ -1,13 +1,11 @@
1
+ import { spawn } from "child_process";
2
+ import { platform } from "os";
1
3
  const activeChildren = new Map();
2
- /** Register a kill callback for a tool call id (executor timeout/abort). */
3
- export function registerKillable(callId, kill) {
4
- activeChildren.set(callId, { kill });
5
- }
6
- /** Unregister a tool call's kill callback once its handler finished. */
7
- export function unregisterKillable(callId) {
8
- activeChildren.delete(callId);
9
- }
10
- /** Invoke (and drop) the kill callback registered for `callId`. */
4
+ /**
5
+ * Kill a foreground command previously registered by `callId` (used by the
6
+ * tool executor when the execution timeout fires). Returns true when a child
7
+ * was found and killed.
8
+ */
11
9
  export function killByCallId(callId) {
12
10
  const entry = activeChildren.get(callId);
13
11
  if (!entry)
@@ -21,3 +19,106 @@ export function killByCallId(callId) {
21
19
  }
22
20
  return true;
23
21
  }
22
+ function killTree(child) {
23
+ const pid = child.pid;
24
+ if (!pid)
25
+ return;
26
+ if (platform() === "win32") {
27
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
28
+ windowsHide: true,
29
+ stdio: "ignore",
30
+ });
31
+ return;
32
+ }
33
+ try {
34
+ // `detached: true` makes the child a process-group leader on POSIX,
35
+ // so killing -pid terminates the whole tree.
36
+ process.kill(-pid, "SIGTERM");
37
+ }
38
+ catch {
39
+ try {
40
+ child.kill("SIGKILL");
41
+ }
42
+ catch {
43
+ /* already dead */
44
+ }
45
+ }
46
+ }
47
+ /**
48
+ * Run a shell command asynchronously (non-blocking event loop) and collect
49
+ * stdout/stderr. The child is killed on timeout and can be killed externally
50
+ * via `killByCallId` (used by the tool executor timeout).
51
+ */
52
+ export function runCommand(command, options = {}) {
53
+ const { cwd, callId, timeoutMs = 120_000, maxBuffer = 10 * 1024 * 1024, } = options;
54
+ return new Promise((resolve) => {
55
+ let timedOut = false;
56
+ let stdout = "";
57
+ let stderr = "";
58
+ const child = spawn(command, {
59
+ cwd,
60
+ shell: true,
61
+ windowsHide: true,
62
+ detached: platform() !== "win32",
63
+ stdio: ["ignore", "pipe", "pipe"],
64
+ });
65
+ const stdoutRef = { value: stdout };
66
+ const stderrRef = { value: stderr };
67
+ // On Windows, child processes spawned via cmd.exe output in the OEM code
68
+ // page (typically CP866 for Russian), NOT UTF-8 — even when the parent
69
+ // console reports code page 65001 via `chcp`. Always decode with ibm866
70
+ // so that Cyrillic and other non-ASCII text is not garbled.
71
+ const decoder = platform() === "win32" ? new TextDecoder("ibm866") : null;
72
+ const decodeChunk = (chunk) => decoder ? decoder.decode(chunk, { stream: true }) : chunk.toString("utf-8");
73
+ child.stdout?.on("data", (chunk) => {
74
+ const text = decodeChunk(chunk);
75
+ const next = stdoutRef.value.length + text.length;
76
+ if (next > maxBuffer) {
77
+ stdoutRef.value =
78
+ stdoutRef.value.slice(stdoutRef.value.length + text.length - maxBuffer) + text;
79
+ }
80
+ else {
81
+ stdoutRef.value = stdoutRef.value + text;
82
+ }
83
+ });
84
+ child.stderr?.on("data", (chunk) => {
85
+ const text = decodeChunk(chunk);
86
+ const next = stderrRef.value.length + text.length;
87
+ if (next > maxBuffer) {
88
+ stderrRef.value =
89
+ stderrRef.value.slice(stderrRef.value.length + text.length - maxBuffer) + text;
90
+ }
91
+ else {
92
+ stderrRef.value = stderrRef.value + text;
93
+ }
94
+ });
95
+ const entry = {
96
+ kill: () => killTree(child),
97
+ };
98
+ if (callId) {
99
+ activeChildren.set(callId, entry);
100
+ }
101
+ const timer = setTimeout(() => {
102
+ timedOut = true;
103
+ killTree(child);
104
+ }, timeoutMs);
105
+ child.on("error", () => {
106
+ clearTimeout(timer);
107
+ if (callId)
108
+ activeChildren.delete(callId);
109
+ resolve({
110
+ stdout: stdoutRef.value,
111
+ stderr: stderrRef.value,
112
+ code: null,
113
+ signal: null,
114
+ timedOut,
115
+ });
116
+ });
117
+ child.on("close", (code, signal) => {
118
+ clearTimeout(timer);
119
+ if (callId)
120
+ activeChildren.delete(callId);
121
+ resolve({ stdout: stdoutRef.value, stderr: stderrRef.value, code, signal, timedOut });
122
+ });
123
+ });
124
+ }