micro-models-agent 0.39.0 → 0.40.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/bin/mma.mjs +41 -41
- package/dist/cli/commands.js +116 -3
- package/dist/cli/main.js +35 -8
- package/dist/cli/repl-commands.js +633 -0
- package/dist/cli/repl.js +110 -611
- package/dist/cli/setup.js +32 -12
- package/dist/config/config.js +46 -30
- package/dist/config/defaults.js +10 -1
- package/dist/config/security.js +15 -8
- package/dist/core/agent-moe.js +24 -12
- package/dist/core/agent.js +281 -47
- package/dist/core/bootstrap.js +52 -36
- package/dist/core/session-logger.js +35 -2
- package/dist/core/workspace.js +76 -0
- package/dist/i18n/en.json +79 -15
- package/dist/i18n/index.js +12 -9
- package/dist/i18n/ru.json +79 -15
- package/dist/index.js +13 -13
- package/dist/llm/openai-compat.js +39 -10
- package/dist/logger/app-logger.js +83 -16
- package/dist/logger/file-log.js +151 -0
- package/dist/main.js +537 -284
- package/dist/modules/browser/bridge-server.mjs +113 -105
- package/dist/modules/browser/session.js +108 -60
- package/dist/modules/certification/cli.js +176 -0
- package/dist/modules/certification/fact-checker.js +84 -0
- package/dist/modules/certification/loader.js +111 -0
- package/dist/modules/certification/manifest.js +50 -0
- package/dist/modules/certification/runner.js +162 -0
- package/dist/modules/certification/scenarios.js +124 -0
- package/dist/modules/certification/types.js +1 -0
- package/dist/modules/context/manager.js +119 -10
- package/dist/modules/execution/auditor.js +33 -39
- package/dist/modules/execution/index.js +8 -6
- package/dist/modules/execution/module.js +474 -32
- package/dist/modules/execution/moe-executor.js +97 -40
- package/dist/modules/execution/plan-coverage.js +68 -0
- package/dist/modules/execution/plan-persister.js +46 -0
- package/dist/modules/execution/plan-store.js +159 -0
- package/dist/modules/execution/planner.js +63 -13
- package/dist/modules/execution/stuck-detector.js +252 -39
- package/dist/modules/execution/tracker.js +21 -7
- package/dist/modules/execution/verifier.js +46 -17
- package/dist/modules/hallucination/confidence.js +7 -2
- package/dist/modules/hallucination/consistency.js +8 -42
- package/dist/modules/hallucination/detector.js +26 -21
- package/dist/modules/hallucination/factual.js +170 -150
- package/dist/modules/hallucination/index.js +5 -4
- package/dist/modules/hallucination/js-identifiers.js +72 -0
- package/dist/modules/hallucination/llm-judge.js +103 -0
- package/dist/modules/index.js +5 -5
- package/dist/modules/lsp/client.js +235 -0
- package/dist/modules/lsp/config.js +81 -0
- package/dist/modules/lsp/index.js +3 -0
- package/dist/modules/lsp/module.js +68 -0
- package/dist/modules/lsp/types.js +1 -0
- package/dist/modules/mcp/client.js +8 -2
- package/dist/modules/memory/store.js +4 -0
- package/dist/modules/plugins/builtin/lint-on-write.js +143 -38
- package/dist/modules/processes/index.js +1 -2
- package/dist/modules/processes/registry.js +125 -35
- package/dist/modules/processes/runner.js +9 -110
- package/dist/modules/security/audit-log.js +30 -10
- package/dist/modules/security/command-validator.js +42 -16
- package/dist/modules/security/content-scanner.js +9 -8
- package/dist/modules/security/network-validator.js +2 -2
- package/dist/modules/security/path-validator.js +64 -10
- package/dist/modules/security/security-policies.js +221 -67
- package/dist/modules/security/session-encryption.js +42 -25
- package/dist/modules/session/manager.js +15 -10
- package/dist/modules/session/store.js +62 -8
- package/dist/modules/skills/index.js +2 -3
- package/dist/modules/skills/module.js +10 -23
- package/dist/tools/bash.js +287 -90
- package/dist/tools/create-dir.js +0 -1
- package/dist/tools/delete-file.js +0 -1
- package/dist/tools/edit-file.js +10 -8
- package/dist/tools/executor.js +57 -7
- package/dist/tools/grep-tool.js +51 -29
- package/dist/tools/index.js +55 -40
- package/dist/tools/load-skill.js +14 -18
- package/dist/tools/move-file.js +3 -2
- package/dist/tools/pipeline-run.js +1 -1
- package/dist/tools/read-file.js +15 -5
- package/dist/tools/search-history.js +42 -22
- package/dist/tools/subagent.js +21 -12
- package/dist/tools/web-browse.js +54 -25
- package/dist/tools/web-fetch.js +60 -34
- package/dist/tools/web-search.js +39 -20
- package/dist/tools/write-file.js +13 -10
- package/dist/ui/diff.js +9 -16
- package/dist/ui/renderer.js +69 -6
- package/package.json +48 -45
- package/dist/modules/context/history.js +0 -15
- package/dist/modules/processes/detect.js +0 -34
- package/dist/modules/skills/matcher.js +0 -27
|
@@ -1,27 +1,62 @@
|
|
|
1
|
-
import { execSync } from
|
|
2
|
-
import { existsSync, readFileSync } from
|
|
3
|
-
import { resolve, extname, join } from
|
|
4
|
-
|
|
5
|
-
let projectTypeCheckTimestamp = 0;
|
|
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";
|
|
6
5
|
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
|
+
}
|
|
7
40
|
export class LintOnWritePlugin {
|
|
8
|
-
name =
|
|
41
|
+
name = "lint-on-write";
|
|
9
42
|
priority = 10;
|
|
43
|
+
_checkPromise = null;
|
|
44
|
+
_checkTimestamp = 0;
|
|
10
45
|
async onAfterTool(ctx, call, result) {
|
|
11
|
-
if (call.name !==
|
|
46
|
+
if (call.name !== "write_file" && call.name !== "edit_file") {
|
|
12
47
|
return;
|
|
13
48
|
}
|
|
14
49
|
if (!result.success) {
|
|
15
50
|
return;
|
|
16
51
|
}
|
|
17
|
-
const path = String(call.arguments.path ||
|
|
52
|
+
const path = String(call.arguments.path || "");
|
|
18
53
|
if (!path)
|
|
19
54
|
return;
|
|
20
55
|
const fullPath = resolve(ctx.baseDir, path);
|
|
21
56
|
if (!existsSync(fullPath))
|
|
22
57
|
return;
|
|
23
58
|
const ext = extname(fullPath);
|
|
24
|
-
const syntaxError = this.checkSyntax(fullPath, ext, ctx.baseDir);
|
|
59
|
+
const syntaxError = await this.checkSyntax(fullPath, ext, ctx.baseDir);
|
|
25
60
|
if (syntaxError) {
|
|
26
61
|
result.output += `\n\n[Syntax check failed]: ${syntaxError}`;
|
|
27
62
|
return;
|
|
@@ -29,32 +64,50 @@ export class LintOnWritePlugin {
|
|
|
29
64
|
await this.runProjectLint(ctx, result);
|
|
30
65
|
await this.runProjectTypeCheck(ctx, result);
|
|
31
66
|
}
|
|
32
|
-
checkSyntax(filePath, ext, baseDir) {
|
|
33
|
-
if (ext ===
|
|
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
|
+
}
|
|
34
84
|
try {
|
|
35
|
-
|
|
85
|
+
await runAsync(`bun build --no-bundle --target=bun "${filePath}"`, baseDir, 10000);
|
|
86
|
+
syntaxCache.set(filePath, { hash, error: null });
|
|
36
87
|
return null;
|
|
37
88
|
}
|
|
38
89
|
catch (err) {
|
|
39
|
-
if (err.status === 127 ||
|
|
90
|
+
if (err.status === 127 ||
|
|
91
|
+
err.message.includes("not found") ||
|
|
92
|
+
err.message.includes("ENOENT")) {
|
|
40
93
|
return null;
|
|
41
94
|
}
|
|
42
|
-
const stderr = err.stderr?.toString() || err.stdout?.toString() ||
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
return null;
|
|
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();
|
|
48
100
|
}
|
|
49
101
|
}
|
|
50
|
-
if (ext ===
|
|
102
|
+
if (ext === ".js" || ext === ".jsx") {
|
|
51
103
|
try {
|
|
52
|
-
|
|
104
|
+
await runAsync(`node --check "${filePath}"`, baseDir, 5000);
|
|
53
105
|
return null;
|
|
54
106
|
}
|
|
55
107
|
catch (err) {
|
|
56
|
-
const stderr = err.stderr?.toString() ||
|
|
57
|
-
const firstError = stderr.split(
|
|
108
|
+
const stderr = err.stderr?.toString() || "";
|
|
109
|
+
const firstError = stderr.split("\n").find((line) => line.trim()) ||
|
|
110
|
+
"JavaScript syntax error";
|
|
58
111
|
return firstError.trim();
|
|
59
112
|
}
|
|
60
113
|
}
|
|
@@ -62,18 +115,18 @@ export class LintOnWritePlugin {
|
|
|
62
115
|
}
|
|
63
116
|
async runProjectLint(ctx, result) {
|
|
64
117
|
try {
|
|
65
|
-
const packageJsonPath = join(ctx.baseDir,
|
|
118
|
+
const packageJsonPath = join(ctx.baseDir, "package.json");
|
|
66
119
|
if (!existsSync(packageJsonPath)) {
|
|
67
120
|
return;
|
|
68
121
|
}
|
|
69
|
-
const packageJson = JSON.parse(readFileSync(packageJsonPath,
|
|
122
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
70
123
|
const lintScript = packageJson.scripts?.lint;
|
|
71
124
|
if (!lintScript) {
|
|
72
125
|
return;
|
|
73
126
|
}
|
|
74
127
|
ctx.logger.debug(`Running lint: ${lintScript}`);
|
|
75
|
-
|
|
76
|
-
ctx.logger.debug(
|
|
128
|
+
await runAsync(lintScript, ctx.baseDir, 30_000);
|
|
129
|
+
ctx.logger.debug("Lint passed");
|
|
77
130
|
}
|
|
78
131
|
catch (err) {
|
|
79
132
|
ctx.logger.warn(`Lint failed: ${err.message}`);
|
|
@@ -81,41 +134,93 @@ export class LintOnWritePlugin {
|
|
|
81
134
|
}
|
|
82
135
|
}
|
|
83
136
|
async runProjectTypeCheck(ctx, result) {
|
|
84
|
-
const tsconfigPath = join(ctx.baseDir,
|
|
137
|
+
const tsconfigPath = join(ctx.baseDir, "tsconfig.json");
|
|
85
138
|
if (!existsSync(tsconfigPath)) {
|
|
86
139
|
return;
|
|
87
140
|
}
|
|
88
141
|
const now = Date.now();
|
|
89
|
-
if (
|
|
90
|
-
|
|
142
|
+
if (this._checkPromise &&
|
|
143
|
+
now - this._checkTimestamp < TYPE_CHECK_DEBOUNCE_MS) {
|
|
144
|
+
const error = await this._checkPromise;
|
|
91
145
|
if (error) {
|
|
92
146
|
result.output += `\n\n[Project typecheck failed]: ${error}`;
|
|
93
147
|
}
|
|
94
148
|
return;
|
|
95
149
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const error = await
|
|
150
|
+
this._checkTimestamp = now;
|
|
151
|
+
this._checkPromise = this.runTscCheck(ctx.baseDir);
|
|
152
|
+
const error = await this._checkPromise;
|
|
99
153
|
if (error) {
|
|
100
154
|
result.output += `\n\n[Project typecheck failed]: ${error}`;
|
|
101
155
|
}
|
|
102
156
|
}
|
|
103
157
|
async runTscCheck(baseDir) {
|
|
104
158
|
try {
|
|
105
|
-
|
|
159
|
+
await runAsync(`npx tsc --noEmit --skipLibCheck`, baseDir, 30000);
|
|
106
160
|
return null;
|
|
107
161
|
}
|
|
108
162
|
catch (err) {
|
|
109
|
-
if (err.status === 127 ||
|
|
163
|
+
if (err.status === 127 ||
|
|
164
|
+
err.message.includes("not found") ||
|
|
165
|
+
err.message.includes("ENOENT")) {
|
|
110
166
|
return null;
|
|
111
167
|
}
|
|
112
|
-
const stderr = err.stderr?.toString() || err.stdout?.toString() ||
|
|
113
|
-
if (stderr.includes(
|
|
114
|
-
const firstError = stderr
|
|
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";
|
|
115
174
|
return firstError.trim();
|
|
116
175
|
}
|
|
117
176
|
return null;
|
|
118
177
|
}
|
|
119
178
|
}
|
|
120
179
|
}
|
|
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
|
+
}
|
|
121
226
|
export const plugin = new LintOnWritePlugin();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
1
|
+
import { spawn, spawnSync } from "child_process";
|
|
2
2
|
import { platform } from "os";
|
|
3
|
-
const MAX_LOG_LINES =
|
|
3
|
+
const MAX_LOG_LINES = 2000;
|
|
4
4
|
const MAX_KEPT_PROCESSES = 20;
|
|
5
5
|
let seq = 0;
|
|
6
6
|
function killTree(child) {
|
|
@@ -8,10 +8,16 @@ function killTree(child) {
|
|
|
8
8
|
if (!pid)
|
|
9
9
|
return;
|
|
10
10
|
if (platform() === "win32") {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
}
|
|
15
21
|
return;
|
|
16
22
|
}
|
|
17
23
|
try {
|
|
@@ -29,9 +35,10 @@ function killTree(child) {
|
|
|
29
35
|
class ProcessRegistry {
|
|
30
36
|
procs = new Map();
|
|
31
37
|
children = new Map();
|
|
38
|
+
exitWaiters = new Map();
|
|
32
39
|
/**
|
|
33
|
-
* Start a
|
|
34
|
-
*
|
|
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.
|
|
35
42
|
*/
|
|
36
43
|
start(command, cwd, sessionId) {
|
|
37
44
|
const id = `proc_${Date.now()}_${++seq}`;
|
|
@@ -48,46 +55,98 @@ class ProcessRegistry {
|
|
|
48
55
|
};
|
|
49
56
|
this.trimOldEntries();
|
|
50
57
|
this.procs.set(id, entry);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
74
|
this.children.set(id, child);
|
|
59
75
|
entry.pid = child.pid ?? 0;
|
|
60
|
-
|
|
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
|
+
};
|
|
61
107
|
const append = (chunk) => {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
108
|
+
const decoded = decoder.decode(chunk, { stream: true });
|
|
109
|
+
buf += decoded;
|
|
110
|
+
const lines = buf.split(/\r?\n/);
|
|
111
|
+
buf = lines.pop() ?? "";
|
|
65
112
|
for (const line of lines) {
|
|
66
|
-
|
|
67
|
-
entry.log.shift();
|
|
68
|
-
}
|
|
69
|
-
entry.log.push(line);
|
|
113
|
+
pushLine(line);
|
|
70
114
|
}
|
|
71
115
|
};
|
|
116
|
+
const flush = () => {
|
|
117
|
+
buf += decoder.decode();
|
|
118
|
+
if (buf) {
|
|
119
|
+
pushLine(buf);
|
|
120
|
+
buf = "";
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const resolveExit = () => {
|
|
124
|
+
this.exitWaiters.get(id)?.resolve();
|
|
125
|
+
};
|
|
72
126
|
child.stdout?.on("data", append);
|
|
73
127
|
child.stderr?.on("data", append);
|
|
74
128
|
child.on("error", (err) => {
|
|
75
129
|
if (entry.status === "running") {
|
|
76
130
|
entry.status = "killed";
|
|
77
131
|
}
|
|
132
|
+
entry.spawnError = err.message;
|
|
78
133
|
append(Buffer.from(`[process error] ${err.message}`));
|
|
134
|
+
resolveExit();
|
|
79
135
|
});
|
|
80
136
|
child.on("close", (code) => {
|
|
137
|
+
flush();
|
|
81
138
|
if (entry.status === "running") {
|
|
82
139
|
entry.status = "exited";
|
|
83
140
|
}
|
|
84
141
|
entry.exitCode = code;
|
|
85
142
|
this.children.delete(id);
|
|
86
|
-
|
|
87
|
-
entry.log.push(partial);
|
|
88
|
-
partial = "";
|
|
89
|
-
}
|
|
143
|
+
resolveExit();
|
|
90
144
|
});
|
|
145
|
+
let resolve;
|
|
146
|
+
const promise = new Promise((r) => {
|
|
147
|
+
resolve = r;
|
|
148
|
+
});
|
|
149
|
+
this.exitWaiters.set(id, { promise, resolve });
|
|
91
150
|
return entry;
|
|
92
151
|
}
|
|
93
152
|
list(sessionId) {
|
|
@@ -128,20 +187,51 @@ class ProcessRegistry {
|
|
|
128
187
|
}
|
|
129
188
|
return targets.length;
|
|
130
189
|
}
|
|
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
|
+
}
|
|
131
228
|
trimOldEntries() {
|
|
132
229
|
if (this.procs.size < MAX_KEPT_PROCESSES)
|
|
133
230
|
return;
|
|
134
231
|
const sorted = Array.from(this.procs.values()).sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
135
232
|
const toRemove = sorted.slice(0, sorted.length - MAX_KEPT_PROCESSES + 1);
|
|
136
233
|
for (const entry of toRemove) {
|
|
137
|
-
|
|
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);
|
|
234
|
+
this.remove(entry.id);
|
|
145
235
|
}
|
|
146
236
|
}
|
|
147
237
|
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { spawn } from "child_process";
|
|
2
|
-
import { platform } from "os";
|
|
3
1
|
const activeChildren = new Map();
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*/
|
|
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`. */
|
|
9
11
|
export function killByCallId(callId) {
|
|
10
12
|
const entry = activeChildren.get(callId);
|
|
11
13
|
if (!entry)
|
|
@@ -19,106 +21,3 @@ export function killByCallId(callId) {
|
|
|
19
21
|
}
|
|
20
22
|
return true;
|
|
21
23
|
}
|
|
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
|
-
}
|
|
@@ -1,28 +1,48 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, appendFileSync } from "fs";
|
|
2
|
-
import { resolve } from "path";
|
|
2
|
+
import { resolve, join } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
import { globalAuditNotifier } from "./audit-notifier";
|
|
5
|
+
let _globalAuditDir = resolve(homedir(), ".mma", "logs");
|
|
6
|
+
let _sessionAuditDir = null;
|
|
7
|
+
function getAuditDir() {
|
|
8
|
+
return _sessionAuditDir ?? _globalAuditDir;
|
|
9
|
+
}
|
|
5
10
|
/**
|
|
6
|
-
*
|
|
11
|
+
* Set the session directory for audit logs. When set, all audit entries
|
|
12
|
+
* are written to the session-specific directory instead of the global one.
|
|
7
13
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
14
|
+
export function setAuditSessionDir(dir) {
|
|
15
|
+
_sessionAuditDir = dir;
|
|
16
|
+
if (!existsSync(dir)) {
|
|
17
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
10
20
|
/**
|
|
11
|
-
*
|
|
21
|
+
* Clear the session directory — fall back to global audit dir.
|
|
12
22
|
*/
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
23
|
+
export function clearAuditSessionDir() {
|
|
24
|
+
_sessionAuditDir = null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Set the global audit log directory (used when no session dir is active).
|
|
28
|
+
*/
|
|
29
|
+
export function setGlobalAuditDir(dir) {
|
|
30
|
+
_globalAuditDir = dir;
|
|
31
|
+
if (!existsSync(dir)) {
|
|
32
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
16
33
|
}
|
|
17
34
|
}
|
|
18
35
|
/**
|
|
19
36
|
* Write an audit log entry
|
|
20
37
|
*/
|
|
21
38
|
export function logAudit(entry) {
|
|
22
|
-
|
|
39
|
+
const dir = getAuditDir();
|
|
40
|
+
if (!existsSync(dir)) {
|
|
41
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
42
|
+
}
|
|
23
43
|
try {
|
|
24
44
|
const logEntry = JSON.stringify(entry);
|
|
25
|
-
appendFileSync(
|
|
45
|
+
appendFileSync(join(dir, "audit.jsonl"), logEntry + "\n", "utf8");
|
|
26
46
|
}
|
|
27
47
|
catch {
|
|
28
48
|
// Silently fail if we can't write to audit log
|