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.
Files changed (96) hide show
  1. package/bin/mma.mjs +41 -41
  2. package/dist/cli/commands.js +116 -3
  3. package/dist/cli/main.js +35 -8
  4. package/dist/cli/repl-commands.js +633 -0
  5. package/dist/cli/repl.js +110 -611
  6. package/dist/cli/setup.js +32 -12
  7. package/dist/config/config.js +46 -30
  8. package/dist/config/defaults.js +10 -1
  9. package/dist/config/security.js +15 -8
  10. package/dist/core/agent-moe.js +24 -12
  11. package/dist/core/agent.js +281 -47
  12. package/dist/core/bootstrap.js +52 -36
  13. package/dist/core/session-logger.js +35 -2
  14. package/dist/core/workspace.js +76 -0
  15. package/dist/i18n/en.json +79 -15
  16. package/dist/i18n/index.js +12 -9
  17. package/dist/i18n/ru.json +79 -15
  18. package/dist/index.js +13 -13
  19. package/dist/llm/openai-compat.js +39 -10
  20. package/dist/logger/app-logger.js +83 -16
  21. package/dist/logger/file-log.js +151 -0
  22. package/dist/main.js +537 -284
  23. package/dist/modules/browser/bridge-server.mjs +113 -105
  24. package/dist/modules/browser/session.js +108 -60
  25. package/dist/modules/certification/cli.js +176 -0
  26. package/dist/modules/certification/fact-checker.js +84 -0
  27. package/dist/modules/certification/loader.js +111 -0
  28. package/dist/modules/certification/manifest.js +50 -0
  29. package/dist/modules/certification/runner.js +162 -0
  30. package/dist/modules/certification/scenarios.js +124 -0
  31. package/dist/modules/certification/types.js +1 -0
  32. package/dist/modules/context/manager.js +119 -10
  33. package/dist/modules/execution/auditor.js +33 -39
  34. package/dist/modules/execution/index.js +8 -6
  35. package/dist/modules/execution/module.js +474 -32
  36. package/dist/modules/execution/moe-executor.js +97 -40
  37. package/dist/modules/execution/plan-coverage.js +68 -0
  38. package/dist/modules/execution/plan-persister.js +46 -0
  39. package/dist/modules/execution/plan-store.js +159 -0
  40. package/dist/modules/execution/planner.js +63 -13
  41. package/dist/modules/execution/stuck-detector.js +252 -39
  42. package/dist/modules/execution/tracker.js +21 -7
  43. package/dist/modules/execution/verifier.js +46 -17
  44. package/dist/modules/hallucination/confidence.js +7 -2
  45. package/dist/modules/hallucination/consistency.js +8 -42
  46. package/dist/modules/hallucination/detector.js +26 -21
  47. package/dist/modules/hallucination/factual.js +170 -150
  48. package/dist/modules/hallucination/index.js +5 -4
  49. package/dist/modules/hallucination/js-identifiers.js +72 -0
  50. package/dist/modules/hallucination/llm-judge.js +103 -0
  51. package/dist/modules/index.js +5 -5
  52. package/dist/modules/lsp/client.js +235 -0
  53. package/dist/modules/lsp/config.js +81 -0
  54. package/dist/modules/lsp/index.js +3 -0
  55. package/dist/modules/lsp/module.js +68 -0
  56. package/dist/modules/lsp/types.js +1 -0
  57. package/dist/modules/mcp/client.js +8 -2
  58. package/dist/modules/memory/store.js +4 -0
  59. package/dist/modules/plugins/builtin/lint-on-write.js +143 -38
  60. package/dist/modules/processes/index.js +1 -2
  61. package/dist/modules/processes/registry.js +125 -35
  62. package/dist/modules/processes/runner.js +9 -110
  63. package/dist/modules/security/audit-log.js +30 -10
  64. package/dist/modules/security/command-validator.js +42 -16
  65. package/dist/modules/security/content-scanner.js +9 -8
  66. package/dist/modules/security/network-validator.js +2 -2
  67. package/dist/modules/security/path-validator.js +64 -10
  68. package/dist/modules/security/security-policies.js +221 -67
  69. package/dist/modules/security/session-encryption.js +42 -25
  70. package/dist/modules/session/manager.js +15 -10
  71. package/dist/modules/session/store.js +62 -8
  72. package/dist/modules/skills/index.js +2 -3
  73. package/dist/modules/skills/module.js +10 -23
  74. package/dist/tools/bash.js +287 -90
  75. package/dist/tools/create-dir.js +0 -1
  76. package/dist/tools/delete-file.js +0 -1
  77. package/dist/tools/edit-file.js +10 -8
  78. package/dist/tools/executor.js +57 -7
  79. package/dist/tools/grep-tool.js +51 -29
  80. package/dist/tools/index.js +55 -40
  81. package/dist/tools/load-skill.js +14 -18
  82. package/dist/tools/move-file.js +3 -2
  83. package/dist/tools/pipeline-run.js +1 -1
  84. package/dist/tools/read-file.js +15 -5
  85. package/dist/tools/search-history.js +42 -22
  86. package/dist/tools/subagent.js +21 -12
  87. package/dist/tools/web-browse.js +54 -25
  88. package/dist/tools/web-fetch.js +60 -34
  89. package/dist/tools/web-search.js +39 -20
  90. package/dist/tools/write-file.js +13 -10
  91. package/dist/ui/diff.js +9 -16
  92. package/dist/ui/renderer.js +69 -6
  93. package/package.json +48 -45
  94. package/dist/modules/context/history.js +0 -15
  95. package/dist/modules/processes/detect.js +0 -34
  96. package/dist/modules/skills/matcher.js +0 -27
@@ -5,12 +5,17 @@ import { SessionFileEncryptor } from "../security/session-encryption";
5
5
  export class SessionStore {
6
6
  baseDir;
7
7
  encryptor = null;
8
+ // In-memory meta cache to avoid O(N²) disk reads on appendMessage.
9
+ _metaCache = new Map();
8
10
  constructor(baseDir, encryptionConfig) {
9
11
  this.baseDir = baseDir;
10
12
  if (encryptionConfig?.enabled) {
11
13
  this.encryptor = new SessionFileEncryptor(encryptionConfig);
12
14
  }
13
15
  }
16
+ getSessionDir(id) {
17
+ return join(this.baseDir, id);
18
+ }
14
19
  /**
15
20
  * Update encryption configuration
16
21
  */
@@ -47,6 +52,7 @@ export class SessionStore {
47
52
  return existsSync(this.metaPath(id));
48
53
  }
49
54
  saveMeta(id, meta) {
55
+ this._metaCache.set(id, meta);
50
56
  const dir = this.sessionDir(id);
51
57
  mkdirSync(dir, { recursive: true });
52
58
  const content = JSON.stringify(meta, null, 2);
@@ -58,13 +64,20 @@ export class SessionStore {
58
64
  }
59
65
  }
60
66
  loadMeta(id) {
67
+ const cached = this._metaCache.get(id);
68
+ if (cached)
69
+ return cached;
61
70
  const path = this.metaPath(id);
62
71
  if (!existsSync(path))
63
72
  return null;
64
73
  try {
65
74
  const raw = readFileSync(path, "utf-8");
66
- const content = this.encryptor ? this.encryptor.decryptFileContent(raw) : raw;
67
- return JSON.parse(content);
75
+ const content = this.encryptor
76
+ ? this.encryptor.decryptFileContent(raw)
77
+ : raw;
78
+ const meta = JSON.parse(content);
79
+ this._metaCache.set(id, meta);
80
+ return meta;
68
81
  }
69
82
  catch {
70
83
  return null;
@@ -94,11 +107,31 @@ export class SessionStore {
94
107
  try {
95
108
  const raw = readFileSync(path, "utf-8");
96
109
  const lines = raw.split("\n").filter(Boolean);
110
+ const parseLine = (line) => {
111
+ try {
112
+ return JSON.parse(line);
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ };
97
118
  if (this.encryptor?.isEnabled()) {
98
- const decryptedLines = lines.map(line => this.encryptor.decryptFileContent(line));
99
- return decryptedLines.map((line) => JSON.parse(line));
119
+ return lines
120
+ .map((line) => {
121
+ try {
122
+ return this.encryptor.decryptFileContent(line);
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ })
128
+ .filter((l) => l !== null)
129
+ .map(parseLine)
130
+ .filter((m) => m !== null);
100
131
  }
101
- return lines.map((line) => JSON.parse(line));
132
+ return lines
133
+ .map(parseLine)
134
+ .filter((m) => m !== null);
102
135
  }
103
136
  catch {
104
137
  return [];
@@ -122,11 +155,31 @@ export class SessionStore {
122
155
  try {
123
156
  const raw = readFileSync(path, "utf-8");
124
157
  const lines = raw.split("\n").filter(Boolean);
158
+ const parseLine = (line) => {
159
+ try {
160
+ return JSON.parse(line);
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ };
125
166
  if (this.encryptor?.isEnabled()) {
126
- const decryptedLines = lines.map(line => this.encryptor.decryptFileContent(line));
127
- return decryptedLines.map((line) => JSON.parse(line));
167
+ return lines
168
+ .map((line) => {
169
+ try {
170
+ return this.encryptor.decryptFileContent(line);
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ })
176
+ .filter((l) => l !== null)
177
+ .map(parseLine)
178
+ .filter((e) => e !== null);
128
179
  }
129
- return lines.map((line) => JSON.parse(line));
180
+ return lines
181
+ .map(parseLine)
182
+ .filter((e) => e !== null);
130
183
  }
131
184
  catch {
132
185
  return [];
@@ -148,6 +201,7 @@ export class SessionStore {
148
201
  return sessions;
149
202
  }
150
203
  deleteSession(id) {
204
+ this._metaCache.delete(id);
151
205
  const dir = this.sessionDir(id);
152
206
  if (existsSync(dir)) {
153
207
  rmSync(dir, { recursive: true, force: true });
@@ -1,3 +1,2 @@
1
- export { SkillsLoader } from './loader';
2
- export { SkillsMatcher } from './matcher';
3
- export { SkillsModule } from './module';
1
+ export { SkillsLoader } from "./loader";
2
+ export { SkillsModule } from "./module";
@@ -3,12 +3,10 @@ export class SkillsModule {
3
3
  name = "skills";
4
4
  availableSkills;
5
5
  loadedSkills = new Map();
6
- matcher;
7
6
  budget;
8
7
  currentTokens = 0;
9
- constructor(availableSkills, matcher, budget) {
8
+ constructor(availableSkills, budget) {
10
9
  this.availableSkills = availableSkills;
11
- this.matcher = matcher;
12
10
  this.budget = budget;
13
11
  }
14
12
  loadByName(name) {
@@ -17,24 +15,10 @@ export class SkillsModule {
17
15
  }
18
16
  const skill = this.availableSkills.find((s) => s.name === name);
19
17
  if (!skill) {
20
- const fuzzyMatches = this.matcher.match(name, this.availableSkills, 1);
21
- if (fuzzyMatches.length > 0) {
22
- return this.tryLoad(fuzzyMatches[0]);
23
- }
24
18
  return { success: false, message: t("skill.not_found", { name }) };
25
19
  }
26
20
  return this.tryLoad(skill);
27
21
  }
28
- loadByMatch(taskDescription) {
29
- const matches = this.matcher.match(taskDescription, this.availableSkills, 1);
30
- if (matches.length === 0) {
31
- return {
32
- success: false,
33
- message: t("skill.no_match", { task: taskDescription }),
34
- };
35
- }
36
- return this.tryLoad(matches[0]);
37
- }
38
22
  unload(name) {
39
23
  const skill = this.loadedSkills.get(name);
40
24
  if (!skill)
@@ -53,7 +37,9 @@ export class SkillsModule {
53
37
  return this.availableSkills.find((s) => s.name === name);
54
38
  }
55
39
  search(query) {
56
- return this.matcher.match(query, this.availableSkills, 10);
40
+ const q = query.toLowerCase();
41
+ return this.availableSkills.filter((s) => s.name.toLowerCase().includes(q) ||
42
+ s.description.toLowerCase().includes(q));
57
43
  }
58
44
  getBudget() {
59
45
  return {
@@ -72,16 +58,17 @@ export class SkillsModule {
72
58
  lines.push("[Available Skills]");
73
59
  lines.push(t("skill.prompt_hint"));
74
60
  for (const skill of this.availableSkills) {
75
- const desc = skill.description.slice(0, 60);
61
+ const desc = skill.description.slice(0, 80);
76
62
  lines.push(`- ${skill.name}: ${desc}`);
77
63
  }
78
- lines.push(t("skill.prompt_fallback"));
79
64
  }
80
65
  if (this.loadedSkills.size > 0) {
66
+ lines.push("");
81
67
  lines.push("[Loaded Skills]");
82
68
  for (const skill of this.loadedSkills.values()) {
83
- const desc = skill.description.slice(0, 80);
84
- lines.push(`- ${skill.name}: ${desc}`);
69
+ lines.push(`--- ${skill.name} (${this.estimateTokens(skill.content)} tokens) ---`);
70
+ lines.push(skill.content);
71
+ lines.push("");
85
72
  }
86
73
  }
87
74
  if (lines.length === 0)
@@ -90,7 +77,7 @@ export class SkillsModule {
90
77
  const tokens = this.estimateTokens(content);
91
78
  return {
92
79
  content,
93
- priority: "normal",
80
+ priority: "high",
94
81
  essential: false,
95
82
  estimatedTokens: tokens,
96
83
  };
@@ -1,61 +1,252 @@
1
- import { isCommandAllowed, sanitizeCommandForLog } from '../modules/security/command-validator';
2
- import { logBashCommand, logSecurityBlock } from '../modules/security/audit-log';
3
- import { getSessionSecurityConfig } from '../modules/security/session-isolation';
4
- import { DEFAULT_SECURITY_CONFIG } from '../config/security';
5
- import { runCommand, processRegistry, isLongRunningCommand } from '../modules/processes';
6
- import { t } from '../i18n/index';
7
- import { platform } from 'os';
8
- import { MAX_PREVIEW_LINES } from './preview';
9
- const BASH_TIMEOUT_MS = 120_000;
1
+ import { isCommandAllowed, sanitizeCommandForLog, } from "../modules/security/command-validator";
2
+ import { logBashCommand, logSecurityBlock, } from "../modules/security/audit-log";
3
+ import { getSessionSecurityConfig } from "../modules/security/session-isolation";
4
+ import { DEFAULT_SECURITY_CONFIG } from "../config/security";
5
+ import { processRegistry, registerKillable, unregisterKillable, } from "../modules/processes";
6
+ import { t } from "../i18n/index";
7
+ import { platform } from "os";
8
+ import { MAX_PREVIEW_LINES } from "./preview";
9
+ /**
10
+ * A command still running after this window is promoted to the background.
11
+ * The decision is based on process *behavior* (still alive), not on matching
12
+ * words in the command text — a command that happens to mention "vite",
13
+ * "server", etc. runs normally, and any genuinely long-running command is
14
+ * caught regardless of how it is written.
15
+ */
16
+ export const BASH_GRACE_MS = 5000;
17
+ /** Short window for explicit `background: true` — surfaces immediate spawn failures (bad cwd, missing shell). */
18
+ const SPAWN_SETTLE_MS = 100;
19
+ let bashGraceMs = BASH_GRACE_MS;
20
+ /** Test hook: override the auto-background grace window. */
21
+ export function setBashGraceMs(ms) {
22
+ bashGraceMs = ms;
23
+ }
24
+ /**
25
+ * Detect a file write via `echo/printf ... > file` — common model habit that
26
+ * breaks in cmd.exe: single quotes are not grouping quotes, `>` only applies
27
+ * to the LAST line of a multi-line command, and double quotes inside the text
28
+ * split the command. Returns the target filename or null.
29
+ */
30
+ export function extractEchoFileWrite(command) {
31
+ if (!/^\s*(?:echo|printf)\b/i.test(command))
32
+ return null;
33
+ const m = command.match(/[>»]{1,2}\s*"?([^"'\s&|]+)"?/i);
34
+ if (!m)
35
+ return null;
36
+ return m[1].replace(/["'']$/g, "");
37
+ }
38
+ /**
39
+ * cmd.exe uses `&` as the command separator, not `;` (bash). The model
40
+ * regularly chains commands with `;` — without translation cmd passes the
41
+ * `;` to the first command as an argument (e.g. `node x.ts;` → ENOENT for
42
+ * "x.ts;"). Replace `;` with `&` only OUTSIDE double-quoted strings so
43
+ * `echo "a;b"` stays intact. (Single quotes are not special in cmd.)
44
+ */
45
+ export function translateSemicolonsForCmd(command) {
46
+ let out = "";
47
+ let inQuotes = false;
48
+ for (let i = 0; i < command.length; i++) {
49
+ const ch = command[i];
50
+ if (ch === '"') {
51
+ inQuotes = !inQuotes;
52
+ out += ch;
53
+ continue;
54
+ }
55
+ out += ch === ";" && !inQuotes ? "&" : ch;
56
+ }
57
+ return out;
58
+ }
10
59
  function adaptCommandForWindows(command) {
11
- if (platform() !== 'win32')
60
+ if (platform() !== "win32")
12
61
  return command;
62
+ // The model sometimes appends `|| true` (bash error-suppression idiom)
63
+ // which PowerShell doesn't understand. Replace with `; exit 0` which
64
+ // forces a successful exit code regardless of the previous command's result.
65
+ if (/\|\|\s*true\b/.test(command)) {
66
+ command = command.replace(/\s*\|\|\s*true\b/g, "; exit 0");
67
+ }
13
68
  // Windows mkdir does not support -p flag, but creates intermediate dirs by default
14
69
  const trimmed = command.trim();
15
- if (trimmed.startsWith('mkdir -p ')) {
16
- return trimmed.replace(/^mkdir -p /, 'mkdir ');
70
+ if (trimmed.startsWith("mkdir -p ")) {
71
+ return trimmed.replace(/^mkdir -p /, "mkdir ");
72
+ }
73
+ if (trimmed === "mkdir -p" || trimmed.startsWith("mkdir -p ")) {
74
+ return trimmed.replace(/mkdir -p/g, "mkdir");
17
75
  }
18
- if (trimmed === 'mkdir -p' || trimmed.startsWith('mkdir -p ')) {
19
- return trimmed.replace(/mkdir -p/g, 'mkdir');
76
+ // Translate simple Unix commands to their cmd.exe equivalents. Only the
77
+ // leading word is rewritten; flags are passed through (ls -la → dir -la,
78
+ // which cmd tolerates). Pipe-using forms are left alone — they would break.
79
+ const firstWord = trimmed.split(/\s+/)[0]?.split(/[\\/]/).pop();
80
+ const translated = firstWord ? UNIX_TO_WIN_TRANSLATE[firstWord] : undefined;
81
+ if (translated &&
82
+ !trimmed.includes("|") &&
83
+ !trimmed.includes(">") &&
84
+ !trimmed.includes("&&") &&
85
+ !trimmed.includes(";")) {
86
+ return trimmed.replace(firstWord, translated);
20
87
  }
21
- // No encoding adaptation needed — runner.ts handles UTF-8 decoding
22
- return command;
88
+ // No encoding adaptation needed — registry.ts handles UTF-8/OEM decoding
89
+ return translateSemicolonsForCmd(command);
23
90
  }
24
91
  /** Common Unix → Windows command mapping for error hints. */
25
92
  const UNIX_TO_WIN_HINTS = {
26
- 'ls': 'Use "dir" or the list_dir tool instead.',
27
- 'pwd': 'Use "echo %cd%" or the file_info tool instead.',
28
- 'cat': 'Use "type" or the read_file tool instead.',
29
- 'cp': 'Use "copy" or the move_file tool instead.',
30
- 'mv': 'Use "move" or the move_file tool instead.',
31
- 'rm': 'Use "del" or the delete_file tool instead.',
32
- 'grep': 'Use "findstr" or the grep tool instead.',
33
- 'chmod': 'Use icacls or the chmod tool instead.',
34
- 'touch': 'Use type nul > file or the write_file tool instead.',
35
- 'find': 'Use "dir /s" or the glob tool instead.',
36
- 'head': 'Use the read_file tool with offset/limit instead.',
37
- 'tail': 'Use the read_file tool instead.',
38
- 'wc': 'Use the read_file tool instead.',
39
- 'diff': 'Use the diff tool instead.',
40
- 'which': 'Use "where" instead.',
41
- 'echo': 'echo works on Windows, but avoid pipes (|).',
93
+ ls: "Use the list_dir tool instead.",
94
+ pwd: "Use the file_info tool instead.",
95
+ cat: "Use the read_file tool instead.",
96
+ cp: "Use the move_file tool instead.",
97
+ mv: "Use the move_file tool instead.",
98
+ rm: "Use the delete_file tool instead.",
99
+ grep: "Use the grep tool instead.",
100
+ chmod: "Use the chmod tool instead.",
101
+ touch: "Use the write_file tool instead.",
102
+ find: "Use the glob tool instead.",
103
+ head: "Use the read_file tool with offset/limit instead.",
104
+ tail: "Use the read_file tool instead.",
105
+ wc: "Use the read_file tool instead.",
106
+ diff: "Use the diff tool instead.",
107
+ which: 'Use "where" instead.',
108
+ echo: "echo works on Windows, but avoid pipes (|).",
109
+ "Get-Content": "Use the read_file tool instead.",
110
+ "Select-Object": "Use the read_file tool with offset/limit instead.",
111
+ };
112
+ /** Unix commands that can be transparently translated to cmd.exe equivalents. */
113
+ const UNIX_TO_WIN_TRANSLATE = {
114
+ ls: "dir",
115
+ pwd: "cd",
116
+ cat: "type",
42
117
  };
118
+ /**
119
+ * Detect when the model mistakes a tool call for a shell command — e.g.
120
+ * `bash` with command "create_dir path=C:\...\src" or "read_file file=x".
121
+ * These are tool invocations, not commands; running them through the shell
122
+ * fails. The matched tool name and raw args are returned so the caller can
123
+ * redirect into the real tool.
124
+ */
125
+ /**
126
+ * Shell commands that must NEVER be treated as mistaken tool calls. The
127
+ * model often writes files via `echo '<code with = signs>'` or reads them
128
+ * via `cat` — those are shell commands, not tool invocations. Without this
129
+ * guard the redirect heuristic fires on any `<word> <text containing =>`
130
+ * and fails with "Unknown tool: echo".
131
+ */
132
+ const NEVER_TOOL_CALLS = new Set([
133
+ "echo",
134
+ "cat",
135
+ "type",
136
+ "printf",
137
+ "touch",
138
+ "mkdir",
139
+ "cp",
140
+ "mv",
141
+ "rm",
142
+ "ls",
143
+ "dir",
144
+ "cd",
145
+ "pwd",
146
+ "grep",
147
+ "find",
148
+ "head",
149
+ "tail",
150
+ "wc",
151
+ "chmod",
152
+ "sed",
153
+ "awk",
154
+ ]);
155
+ export function detectToolCallInBash(command) {
156
+ const match = command.trim().match(/^([\w-]+)\s+(.+)$/s);
157
+ if (!match)
158
+ return null;
159
+ const tool = match[1];
160
+ // Real shell commands (echo, cat, ...) are never mistaken tool calls.
161
+ if (NEVER_TOOL_CALLS.has(tool))
162
+ return null;
163
+ const rest = match[2].trim();
164
+ // Only treat as a tool call when the first word looks like a snake_case
165
+ // tool name and the rest has at least one '=' or a JSON object shape.
166
+ if (!/^[a-z][a-z0-9_]+$/.test(tool))
167
+ return null;
168
+ if (!rest.includes("=") && !rest.startsWith("{"))
169
+ return null;
170
+ return { tool, args: rest };
171
+ }
172
+ /**
173
+ * Parse the raw argument string of a mistaken tool call captured in a bash
174
+ * command into a Record. Supports JSON objects ("{"path": "..."}") and
175
+ * key=value pairs ("path=C:\...\src"). Values keep their literal text.
176
+ */
177
+ export function parseToolArgs(raw) {
178
+ const trimmed = raw.trim();
179
+ if (trimmed.startsWith("{")) {
180
+ try {
181
+ return JSON.parse(trimmed);
182
+ }
183
+ catch {
184
+ /* fall through to key=value parsing */
185
+ }
186
+ }
187
+ const args = {};
188
+ // Tokenize respecting double/single-quoted values.
189
+ const tokens = trimmed.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
190
+ for (const token of tokens) {
191
+ const eq = token.indexOf("=");
192
+ if (eq > 0) {
193
+ const key = token.slice(0, eq);
194
+ const value = token.slice(eq + 1);
195
+ // Strip surrounding quotes from values.
196
+ args[key] = value.replace(/^["']|["']$/g, "");
197
+ }
198
+ }
199
+ return args;
200
+ }
43
201
  export const bashTool = {
44
- name: 'bash',
45
- description: 'Execute a shell command and return its output. Use for running tests, build, git, and shell operations. Long-running commands (dev servers, watchers) start in the background and return a process id immediately — manage them with process_list, process_log, process_kill. Set background=true to force background execution.',
46
- tags: ['shell', 'code'],
202
+ name: "bash",
203
+ description: "Execute a shell command and return its output. Use for running tests, build, git, and shell operations. Commands that are still running after a few seconds are automatically moved to the background and return a process id — manage them with process_list, process_log, process_kill. Set background=true to return a process id immediately for commands you know are long-running (dev servers, watchers).",
204
+ tags: ["shell", "code"],
47
205
  parameters: {
48
- type: 'object',
206
+ type: "object",
49
207
  properties: {
50
- command: { type: 'string', description: 'Shell command to execute' },
51
- workdir: { type: 'string', description: 'Working directory (default: baseDir)' },
52
- background: { type: 'boolean', description: 'Start the command in the background and return immediately with a process id (default: auto-detect long-running commands)' },
208
+ command: { type: "string", description: "Shell command to execute" },
209
+ workdir: {
210
+ type: "string",
211
+ description: "Working directory (default: baseDir)",
212
+ },
213
+ background: {
214
+ type: "boolean",
215
+ description: "Return a process id immediately without waiting (default: commands still running after a few seconds are auto-promoted to the background)",
216
+ },
53
217
  },
54
- required: ['command'],
218
+ required: ["command"],
55
219
  },
56
220
  handler: async (ctx, args) => {
57
221
  const originalCommand = String(args.command);
222
+ // The model sometimes sends a tool invocation (e.g. "create_dir path=...")
223
+ // as a bash command instead of calling the tool directly. Redirect into
224
+ // the real tool so the intent succeeds instead of failing in the shell.
225
+ const toolCall = detectToolCallInBash(originalCommand);
226
+ if (toolCall &&
227
+ toolCall.tool !== "bash" &&
228
+ ctx.toolExecutor &&
229
+ ctx.toolExecutor.hasTool(toolCall.tool)) {
230
+ const redirected = await ctx.toolExecutor.executeByName(toolCall.tool, parseToolArgs(toolCall.args), ctx);
231
+ return {
232
+ success: redirected.success,
233
+ output: `[redirected to tool "${toolCall.tool}"]\n${redirected.output}`,
234
+ };
235
+ }
58
236
  const command = adaptCommandForWindows(originalCommand);
237
+ // echo/printf redirection to a file is unreliable in cmd.exe (single
238
+ // quotes don't group, multi-line commands split, embedded double quotes
239
+ // break the command). Steer the model to write_file instead — it
240
+ // produces correct files every time.
241
+ if (platform() === "win32") {
242
+ const echoWrite = extractEchoFileWrite(originalCommand);
243
+ if (echoWrite) {
244
+ return {
245
+ success: false,
246
+ output: t("bash.echo_write_blocked", { path: echoWrite }),
247
+ };
248
+ }
249
+ }
59
250
  const workdir = args.workdir ? String(args.workdir) : ctx.baseDir;
60
251
  // Get session-specific security config with defaults
61
252
  const appConfig = ctx.config || {};
@@ -77,64 +268,70 @@ export const bashTool = {
77
268
  logBashCommand(ctx.sessionId, sanitizeCommandForLog(command), false, // Will be updated after execution
78
269
  `Working directory: ${workdir}`);
79
270
  }
80
- const background = args.background === true || isLongRunningCommand(command);
81
- if (background) {
82
- const entry = processRegistry.start(command, workdir, ctx.sessionId);
83
- if (securityConfig?.logCommands) {
84
- logBashCommand(ctx.sessionId, sanitizeCommandForLog(command), true, `Started in background: ${entry.id} (PID ${entry.pid})`);
85
- }
86
- const explicit = args.background === true;
87
- return {
88
- success: true,
89
- output: `${t("proc.started", {
90
- id: entry.id,
91
- pid: entry.pid,
92
- command,
93
- })}${explicit ? "" : `\n${t("proc.detected_hint")}`}\n${t("proc.manage_hint", {
94
- id: entry.id,
95
- })}`,
96
- };
271
+ const entry = processRegistry.start(command, workdir, ctx.sessionId);
272
+ if (ctx.activeCallId) {
273
+ // Let the executor abort/kill the child during the grace window.
274
+ registerKillable(ctx.activeCallId, () => processRegistry.kill(entry.id));
97
275
  }
98
276
  try {
99
- const res = await runCommand(command, {
100
- cwd: workdir,
101
- callId: ctx.activeCallId,
102
- timeoutMs: BASH_TIMEOUT_MS,
103
- });
104
- const parts = [res.stdout.trimEnd(), res.stderr.trimEnd()].filter(Boolean);
105
- let output = parts.join("\n");
106
- if (res.timedOut) {
107
- output = `${output ? output + "\n" : ""}${t("proc.timed_out", {
108
- ms: BASH_TIMEOUT_MS,
109
- })}`;
110
- }
111
- else if (!output && res.code !== 0) {
112
- output = `(exit code ${res.code})`;
113
- }
114
- // On Windows, hint about Unix commands that don't work
115
- if (platform() === 'win32' && res.code !== 0) {
116
- const firstWord = command.trim().split(/\s+/)[0]?.split(/[\\/]/).pop();
117
- const hint = firstWord ? UNIX_TO_WIN_HINTS[firstWord] : undefined;
118
- if (hint) {
119
- output = `${output}\n\nHint: "${firstWord}" may not work on Windows. ${hint}`;
277
+ const settleMs = args.background === true ? SPAWN_SETTLE_MS : bashGraceMs;
278
+ const exited = await processRegistry.waitForExit(entry.id, settleMs);
279
+ if (exited) {
280
+ // Command finished (or failed to spawn) within the window.
281
+ if (entry.spawnError) {
282
+ return {
283
+ success: false,
284
+ output: `[process error] ${entry.spawnError}\nHint: check the "workdir" path exists and the command is valid for this OS.`,
285
+ };
286
+ }
287
+ const code = entry.exitCode;
288
+ let output = entry.log.join("\n");
289
+ processRegistry.remove(entry.id);
290
+ if (!output && code !== 0) {
291
+ output = `(exit code ${code})`;
292
+ }
293
+ // On Windows, hint about Unix commands that don't work
294
+ if (platform() === "win32" && code !== 0) {
295
+ const firstWord = command
296
+ .trim()
297
+ .split(/\s+/)[0]
298
+ ?.split(/[\\/]/)
299
+ .pop();
300
+ const hint = firstWord ? UNIX_TO_WIN_HINTS[firstWord] : undefined;
301
+ if (hint) {
302
+ output = `${output}\n\nHint: "${firstWord}" may not work on Windows. ${hint}`;
303
+ }
304
+ }
305
+ // Update audit log with result
306
+ if (securityConfig?.logCommands) {
307
+ logBashCommand(ctx.sessionId, sanitizeCommandForLog(command), code === 0, `Working directory: ${workdir}, Output length: ${output.length}`);
308
+ }
309
+ const lines = output.split("\n");
310
+ if (lines.length > MAX_PREVIEW_LINES) {
311
+ output =
312
+ lines.slice(0, MAX_PREVIEW_LINES).join("\n") +
313
+ `\n... (${lines.length - MAX_PREVIEW_LINES} more lines)`;
120
314
  }
315
+ return { success: code === 0, output };
121
316
  }
122
- // Update audit log with result
317
+ // Still running promote to a background process.
318
+ const explicit = args.background === true;
319
+ const output = `${t("proc.started", {
320
+ id: entry.id,
321
+ pid: entry.pid,
322
+ command,
323
+ })}${explicit ? "" : `\n${t("proc.promoted_hint", { ms: settleMs })}`}\n${t("proc.manage_hint", {
324
+ id: entry.id,
325
+ })}`;
123
326
  if (securityConfig?.logCommands) {
124
- logBashCommand(ctx.sessionId, sanitizeCommandForLog(command), res.code === 0, `Working directory: ${workdir}, Output length: ${res.stdout.length}`);
125
- }
126
- const lines = output.split('\n');
127
- if (lines.length > MAX_PREVIEW_LINES) {
128
- output = lines.slice(0, MAX_PREVIEW_LINES).join('\n') + `\n... (${lines.length - MAX_PREVIEW_LINES} more lines)`;
327
+ logBashCommand(ctx.sessionId, sanitizeCommandForLog(command), true, `Started in background: ${entry.id} (PID ${entry.pid})`);
129
328
  }
130
- return { success: res.code === 0, output };
329
+ return { success: true, output };
131
330
  }
132
- catch (e) {
133
- // Update audit log with failure
134
- if (securityConfig?.logCommands) {
135
- logBashCommand(ctx.sessionId, sanitizeCommandForLog(command), false, `Working directory: ${workdir}, Error: ${e.message?.slice(0, 100) || ""}`);
331
+ finally {
332
+ if (ctx.activeCallId) {
333
+ unregisterKillable(ctx.activeCallId);
136
334
  }
137
- return { success: false, output: e.message || String(e) };
138
335
  }
139
336
  },
140
337
  };
@@ -50,7 +50,6 @@ export const createDirTool = {
50
50
  ctx.fileOperationsCount = currentCount + 1;
51
51
  // Log directory creation
52
52
  logFileWrite(ctx.sessionId, path, true, "Directory created");
53
- ctx.trackCreatedPath?.(path);
54
53
  return { success: true, output: t("file.created", { path }) };
55
54
  },
56
55
  };
@@ -57,7 +57,6 @@ export const deleteFileTool = {
57
57
  ctx.fileOperationsCount = currentCount + 1;
58
58
  // Log successful file deletion
59
59
  logFileDelete(ctx.sessionId, path, true);
60
- ctx.trackDeletedPath?.(path);
61
60
  return { success: true, output: t("file.deleted", { path }), diff };
62
61
  },
63
62
  };