loom-agent 1.2.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 (79) hide show
  1. package/.env.example +25 -0
  2. package/CHANGELOG.md +402 -0
  3. package/LICENSE +21 -0
  4. package/LOOM.md +235 -0
  5. package/README.md +433 -0
  6. package/bin/loom-tui.js +43 -0
  7. package/bin/loom.js +44 -0
  8. package/docs/acp.md +151 -0
  9. package/docs/web.md +205 -0
  10. package/package.json +97 -0
  11. package/scripts/acp-smoke.js +146 -0
  12. package/src/acp/acp-server.js +287 -0
  13. package/src/config/provider-cmd.js +37 -0
  14. package/src/config/settings.js +164 -0
  15. package/src/core/agents.js +361 -0
  16. package/src/core/background-tasks.js +103 -0
  17. package/src/core/cli.js +579 -0
  18. package/src/core/custom-commands.js +70 -0
  19. package/src/core/errors.js +29 -0
  20. package/src/core/events.js +24 -0
  21. package/src/core/file-diffs.js +282 -0
  22. package/src/core/format.js +206 -0
  23. package/src/core/graph.js +257 -0
  24. package/src/core/hooks.js +82 -0
  25. package/src/core/lsp.js +385 -0
  26. package/src/core/memory.js +87 -0
  27. package/src/core/model-router.js +87 -0
  28. package/src/core/permissions.js +327 -0
  29. package/src/core/platform.js +33 -0
  30. package/src/core/plugin-cmd.js +380 -0
  31. package/src/core/restore.js +207 -0
  32. package/src/core/session-store.js +167 -0
  33. package/src/core/session.js +910 -0
  34. package/src/core/subagent-log.js +134 -0
  35. package/src/core/tokens.js +31 -0
  36. package/src/core/update.js +6 -0
  37. package/src/core/usage.js +166 -0
  38. package/src/index.js +41 -0
  39. package/src/mcp/mcp-client.js +201 -0
  40. package/src/mcp/mcp-manager.js +193 -0
  41. package/src/providers/anthropic.js +243 -0
  42. package/src/providers/google.js +29 -0
  43. package/src/providers/index.js +175 -0
  44. package/src/providers/local.js +27 -0
  45. package/src/providers/nvidia.js +85 -0
  46. package/src/providers/openai-compat.js +269 -0
  47. package/src/providers/openai.js +35 -0
  48. package/src/providers/openrouter.js +43 -0
  49. package/src/providers/registry.js +196 -0
  50. package/src/providers/tokenrouter.js +19 -0
  51. package/src/skills/skill-matcher.js +133 -0
  52. package/src/skills/skills-manager.js +213 -0
  53. package/src/tools/index.js +543 -0
  54. package/src/tui/App.tsx +1578 -0
  55. package/src/tui/components/BreadcrumbBar.tsx +34 -0
  56. package/src/tui/components/ChatArea.tsx +518 -0
  57. package/src/tui/components/InputBar.tsx +354 -0
  58. package/src/tui/components/MdText.tsx +105 -0
  59. package/src/tui/components/Modals.tsx +851 -0
  60. package/src/tui/components/PermissionPopup.tsx +264 -0
  61. package/src/tui/components/Sidebar.tsx +182 -0
  62. package/src/tui/components/SplashScreen.tsx +51 -0
  63. package/src/tui/components/SubagentPanel.tsx +217 -0
  64. package/src/tui/components/ToastOverlay.tsx +34 -0
  65. package/src/tui/keybinds.ts +318 -0
  66. package/src/tui/mcp-presets.ts +189 -0
  67. package/src/tui/md-render.ts +228 -0
  68. package/src/tui/store.ts +714 -0
  69. package/src/tui/suite-home.ts +20 -0
  70. package/src/tui/theme.ts +313 -0
  71. package/src/tui/themes.generated.ts +968 -0
  72. package/src/tui/tool-display.ts +176 -0
  73. package/src/tui/toolname.ts +60 -0
  74. package/src/tui/tui-config.ts +28 -0
  75. package/src/tui-open.tsx +51 -0
  76. package/src/web/attach.js +242 -0
  77. package/src/web/graph-view.html +262 -0
  78. package/src/web/index.html +824 -0
  79. package/src/web/web-server.js +470 -0
@@ -0,0 +1,24 @@
1
+ // Tiny synchronous event bus. Core emits lifecycle events (turn start/end,
2
+ // model switch, tool calls, cost recorded); features like the pet, toasts, and
3
+ // telemetry subscribe without coupling.
4
+ const listeners = new Map();
5
+
6
+ function on(event, fn) {
7
+ if (!listeners.has(event)) listeners.set(event, new Set());
8
+ listeners.get(event).add(fn);
9
+ return () => listeners.get(event).delete(fn);
10
+ }
11
+
12
+ function off(event, fn) {
13
+ listeners.get(event)?.delete(fn);
14
+ }
15
+
16
+ function emit(event, data) {
17
+ const set = listeners.get(event);
18
+ if (!set) return;
19
+ for (const fn of Array.from(set)) {
20
+ try { fn(data); } catch {}
21
+ }
22
+ }
23
+
24
+ module.exports = { on, off, emit };
@@ -0,0 +1,282 @@
1
+ // File-diff capture for the chat area — right-side visual panel showing
2
+ // which files the agent edited and the actual +/- hunks.
3
+ // Snapshot BEFORE a write/edit/bash tool runs, then record AFTER to build the diff.
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execSync } = require('child_process');
7
+ const { diffLines } = require('diff');
8
+
9
+ const cwd = process.cwd();
10
+
11
+ // Binary content (png/pdf/zip/exe…) read as utf8 is meaningless garbage, and
12
+ // diffing it fills the panel with noise. Detect it up front and show a short
13
+ // "(binary file)" line instead of the hunks.
14
+ function looksBinary(text) {
15
+ if (!text) return false;
16
+ const sample = text.slice(0, 8192);
17
+ if (sample.indexOf('\0') >= 0) return true;
18
+ let bad = 0;
19
+ for (let i = 0; i < sample.length; i++) {
20
+ const c = sample.charCodeAt(i);
21
+ if (c < 8) bad++;
22
+ }
23
+ return bad / Math.max(1, sample.length) > 0.02;
24
+ }
25
+
26
+ // Mirrors the TUI file walker's ignore list (node_modules, .git, dist, …).
27
+ const IGNORE_RX = /(^|[\/])(node_modules|\.git|dist|build|\.next|\.venv|venv|coverage|__pycache__|\.loom|\.idea|\.vscode)([\/]|$)/i;
28
+
29
+ function relPath(abs) {
30
+ const rel = path.relative(cwd, abs).replace(/\\/g, '/');
31
+ return rel || abs;
32
+ }
33
+
34
+ // Read file content, or null if missing.
35
+ function readFileOrNull(abs) {
36
+ try { return fs.readFileSync(abs, 'utf8'); } catch { return null; }
37
+ }
38
+
39
+ // Track edits per session: abs path -> { before, after }
40
+ const edits = new Map();
41
+
42
+ export function snapshotBefore(filePath) {
43
+ const abs = path.resolve(filePath);
44
+ const before = readFileOrNull(abs);
45
+ edits.set(abs, { before, after: before });
46
+ return { abs, before };
47
+ }
48
+
49
+ // After the tool finished, re-read the file and build the diff vs. the
50
+ // pre-edit snapshot. Accumulates: if the same file is edited twice in one
51
+ // turn, diff is always against the ORIGINAL snapshot (cumulative view).
52
+ export function snapshotAfter(filePath) {
53
+ const abs = path.resolve(filePath);
54
+ const prev = edits.get(abs) || { before: null };
55
+ const after = readFileOrNull(abs);
56
+ prev.after = after;
57
+ edits.set(abs, prev);
58
+ return buildFileDiff(abs, prev.before, prev.after);
59
+ }
60
+
61
+ // ─── Bash tool detection ───
62
+ // The bash tool can modify files outside write/edit (sed -i, git apply, npm
63
+ // install, scaffolders, …). Snapshot the repo state before the call, then
64
+ // diff after it: git repos use `git diff` (accurate, no content copies);
65
+ // non-git directories snapshot small file contents + mtimes.
66
+ let bashBefore = null; // { git: bool, files: Map<abs, {content|null, mtimeMs, size}>, untracked: Set<abs> }
67
+
68
+ function walkFiles(root, depth, out) {
69
+ if (depth > 5 || out.length > 400) return;
70
+ let entries;
71
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; }
72
+ for (const e of entries) {
73
+ const full = path.join(root, e.name);
74
+ if (IGNORE_RX.test(full)) continue;
75
+ if (e.isDirectory()) walkFiles(full, depth + 1, out);
76
+ else out.push(full);
77
+ }
78
+ }
79
+
80
+ function git(args) {
81
+ try {
82
+ return execSync('git ' + args, { cwd: process.cwd(), encoding: 'utf8', timeout: 10000, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ export function snapshotBashBefore() {
89
+ const isGit = git('rev-parse --is-inside-work-tree') === 'true';
90
+ const files = new Map();
91
+ if (isGit) {
92
+ const untracked = new Set();
93
+ const st = git('status --porcelain');
94
+ for (const line of String(st || '').split('\n')) {
95
+ if (/^\?\?/.test(line)) untracked.add(path.resolve(cwd, line.slice(3).trim()));
96
+ }
97
+ bashBefore = { git: true, files, untracked };
98
+ } else {
99
+ const list = [];
100
+ walkFiles(cwd, 0, list);
101
+ for (const f of list) {
102
+ try {
103
+ const st = fs.statSync(f);
104
+ let content = null;
105
+ if (st.size <= 1024 * 1024) content = readFileOrNull(f);
106
+ files.set(f, { content, mtimeMs: st.mtimeMs, size: st.size });
107
+ } catch {}
108
+ }
109
+ bashBefore = { git: false, files, untracked: new Set() };
110
+ }
111
+ return isGit;
112
+ }
113
+
114
+ // After the bash tool finished, return diffs for everything it changed.
115
+ export function diffBashAfter() {
116
+ if (!bashBefore) return [];
117
+ const out = [];
118
+ if (bashBefore.git) {
119
+ const diffText = git('diff --no-color --no-ext-diff') || '';
120
+ for (const d of parseGitDiff(diffText)) out.push(d);
121
+ // New untracked files → all-added diffs.
122
+ const st = git('status --porcelain') || '';
123
+ for (const line of st.split('\n')) {
124
+ if (!/^\?\?/.test(line)) continue;
125
+ const abs = path.resolve(cwd, line.slice(3).trim());
126
+ if (bashBefore.untracked.has(abs)) continue; // was already there
127
+ const d = buildFileDiff(abs, null, readFileOrNull(abs));
128
+ if (d.added || d.removed) out.push(d);
129
+ }
130
+ } else {
131
+ const list = [];
132
+ walkFiles(cwd, 0, list);
133
+ const now = new Map();
134
+ for (const f of list) {
135
+ try {
136
+ const st = fs.statSync(f);
137
+ now.set(f, { mtimeMs: st.mtimeMs, size: st.size });
138
+ } catch {}
139
+ }
140
+ // Changed or deleted files.
141
+ const changed = [];
142
+ for (const [abs, st] of bashBefore.files) {
143
+ const cur = now.get(abs);
144
+ if (!cur || cur.mtimeMs !== st.mtimeMs || cur.size !== st.size) changed.push(abs);
145
+ }
146
+ for (const abs of now.keys()) {
147
+ if (!bashBefore.files.has(abs)) changed.push(abs); // newly created
148
+ }
149
+ for (const abs of changed) {
150
+ const before = bashBefore.files.get(abs)?.content;
151
+ const after = readFileOrNull(abs);
152
+ const d = buildFileDiff(abs, before, after);
153
+ if (d.added || d.removed) out.push(d);
154
+ }
155
+ }
156
+ bashBefore = null;
157
+ return out;
158
+ }
159
+
160
+ // Parse a unified `git diff` into per-file { path, added, removed, lines }.
161
+ function parseGitDiff(text) {
162
+ const out = [];
163
+ const filePat = /^diff --git a\/(.*) b\/(.*)$/;
164
+ let cur = null;
165
+ const lines = String(text).split('\n');
166
+ for (const line of lines) {
167
+ const fm = line.match(filePat);
168
+ if (fm) {
169
+ if (cur && (cur.added || cur.removed)) out.push(cur);
170
+ const p = fm[2].replace(/^"|"$/g, '');
171
+ cur = { path: p, abs: path.resolve(cwd, p), added: 0, removed: 0, lines: /** @type {Array<{kind: string, text: string}>} */ ([]) };
172
+ continue;
173
+ }
174
+ if (!cur) continue;
175
+ if (line.startsWith('@@')) continue;
176
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
177
+ if (line.startsWith('\\')) continue;
178
+ if (/^Binary files/.test(line)) { cur.added++; cur.removed++; cur.lines.push({ kind: 'ctx', text: '(binary file changed)' }); continue; }
179
+ if (line.startsWith('+')) { if (looksBinary(line.slice(1))) continue; cur.added++; cur.lines.push({ kind: 'add', text: line.slice(1) }); }
180
+ else if (line.startsWith('-')) { if (looksBinary(line.slice(1))) continue; cur.removed++; cur.lines.push({ kind: 'del', text: line.slice(1) }); }
181
+ else { if (looksBinary(line.slice(1))) continue; cur.lines.push({ kind: 'ctx', text: line.slice(1) }); }
182
+ }
183
+ if (cur && (cur.added || cur.removed)) out.push(cur);
184
+ for (const d of out) {
185
+ d.lines = trimContext(d.lines, 2);
186
+ d.lines = d.lines.length > 24 ? d.lines.slice(0, 24) : d.lines;
187
+ }
188
+ return out;
189
+ }
190
+
191
+ // Build a compact visual diff: counts + colored hunk lines.
192
+ export function buildFileDiff(abs, before, after) {
193
+ if (looksBinary(after) || looksBinary(before)) {
194
+ // Unchanged binary file (snapshot taken before a tool that didn't touch
195
+ // it) → empty diff; don't show a bogus "0 bytes changed" entry.
196
+ if (before !== null && after !== null && before === after) {
197
+ return { path: relPath(abs), abs, added: 0, removed: 0, lines: [], isNew: false };
198
+ }
199
+ const beforeBytes = before ? Buffer.byteLength(before, 'utf8') : 0;
200
+ const afterBytes = after ? Buffer.byteLength(after, 'utf8') : 0;
201
+ const bytesChanged = Math.max(0, Math.abs(afterBytes - beforeBytes));
202
+ return {
203
+ path: relPath(abs),
204
+ abs,
205
+ added: 1,
206
+ removed: 0,
207
+ lines: [{ kind: 'ctx', text: '(binary file, ' + bytesChanged + ' bytes changed)' }],
208
+ isNew: before === null && after !== null,
209
+ };
210
+ }
211
+ const parts = diffLines(before || '', after || '');
212
+ let added = 0;
213
+ let removed = 0;
214
+ const hunks = [];
215
+ for (const part of parts) {
216
+ if (part.added) { added += part.count; }
217
+ else if (part.removed) { removed += part.count; }
218
+ }
219
+ // Line-by-line with kind markers; trim context to keep the panel compact.
220
+ const lines = diffToLines(parts);
221
+ const trimmed = trimContext(lines, 2);
222
+ const shown = trimmed.length > 24 ? trimmed.slice(0, 24) : trimmed;
223
+ return {
224
+ path: relPath(abs),
225
+ abs,
226
+ added,
227
+ removed,
228
+ lines: shown,
229
+ isNew: before === null && after !== null,
230
+ };
231
+ }
232
+
233
+ function diffToLines(parts) {
234
+ const out = [];
235
+ for (const part of parts) {
236
+ if (part.added) out.push({ kind: 'add', text: part.value });
237
+ else if (part.removed) out.push({ kind: 'del', text: part.value });
238
+ else out.push({ kind: 'ctx', text: part.value });
239
+ }
240
+ return out;
241
+ }
242
+
243
+ // Keep 2 context lines around each hunk, drop the rest (ellipsis marker).
244
+ function trimContext(lines, ctx) {
245
+ if (!lines.length) return lines;
246
+ const keep = [];
247
+ for (let i = 0; i < lines.length; i++) {
248
+ if (lines[i].kind !== 'ctx') {
249
+ for (let j = Math.max(0, i - ctx); j <= Math.min(lines.length - 1, i + ctx); j++) {
250
+ if (!keep.includes(j)) keep.push(j);
251
+ }
252
+ }
253
+ }
254
+ keep.sort((a, b) => a - b);
255
+ if (!keep.length) return [];
256
+ const out = [];
257
+ let last = -10;
258
+ for (const i of keep) {
259
+ if (i - last > 1) out.push({ kind: 'ctx', text: '…' });
260
+ out.push(lines[i]);
261
+ last = i;
262
+ }
263
+ return out;
264
+ }
265
+
266
+ export function clearFileDiffs() { edits.clear(); }
267
+ export function getFileDiffs() {
268
+ const out = [];
269
+ for (const [abs, e] of edits) {
270
+ const d = buildFileDiff(abs, e.before, e.after);
271
+ if (d.added || d.removed) out.push(d);
272
+ }
273
+ return out;
274
+ }
275
+ export function formatDiffCount(d) {
276
+ const parts = [];
277
+ if (d.added) parts.push('+' + d.added);
278
+ if (d.removed) parts.push('-' + d.removed);
279
+ return parts.join(' ') || '±0';
280
+ }
281
+
282
+ export { parseGitDiff };
@@ -0,0 +1,206 @@
1
+ // Formatters — run language-specific formatters on files after the agent
2
+ // writes/edits them (OpenCode-style). Disabled by default; enable via
3
+ // config.json `formatter`: true (all built-ins) or an object of per-formatter
4
+ // overrides / custom formatters.
5
+ //
6
+ // formatter: false → all disabled (default)
7
+ // formatter: true → all built-ins enabled
8
+ // formatter: { ... } → built-ins enabled + overrides
9
+ // formatter: { prettier: { disabled: true } } → disable one
10
+ // formatter: { gofmt: { command: ["gofmt","-w","$FILE"], extensions: [".go"] } } → override
11
+ // formatter: { myfmt: { command: ["fmt", "$FILE"], extensions: [".zzz"] } } → custom
12
+ //
13
+ // The `$FILE` placeholder is replaced with the formatted file's path. When a
14
+ // command has no `$FILE`, the path is appended as the final argument.
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const { spawn } = require('child_process');
18
+ const { loadConfig } = require('../config/settings');
19
+
20
+ /** @typedef {Object} FormatterDef
21
+ * @property {Array<string>} command
22
+ * @property {Array<string>} extensions
23
+ */
24
+
25
+ /** @typedef {Object} FormatResult
26
+ * @property {boolean} formatted
27
+ * @property {string} [id]
28
+ * @property {Array<string>} [command]
29
+ * @property {string} [reason]
30
+ */
31
+
32
+ /** @type {Record<string, FormatterDef>} */
33
+ const DEFAULT_FORMATTERS = {
34
+ prettier: {
35
+ command: ['npx', 'prettier', '--write', '$FILE'],
36
+ extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.html', '.css', '.scss', '.md', '.json', '.json5', '.yaml', '.yml'],
37
+ },
38
+ biome: {
39
+ command: ['npx', 'biome', 'format', '--write', '$FILE'],
40
+ extensions: ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.html', '.css', '.json', '.jsonc', '.md'],
41
+ },
42
+ gofmt: { command: ['gofmt', '-w', '$FILE'], extensions: ['.go'] },
43
+ rustfmt: { command: ['rustfmt', '--edition', '2021', '$FILE'], extensions: ['.rs'] },
44
+ ruff: { command: ['ruff', 'format', '$FILE'], extensions: ['.py', '.pyi'] },
45
+ uv: { command: ['uv', 'fmt', '$FILE'], extensions: ['.py', '.pyi'] },
46
+ clangformat: {
47
+ command: ['clang-format', '-i', '$FILE'],
48
+ extensions: ['.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.ino', '.m', '.mm'],
49
+ },
50
+ shfmt: { command: ['shfmt', '-w', '$FILE'], extensions: ['.sh', '.bash'] },
51
+ ktlint: { command: ['ktlint', '-F', '$FILE'], extensions: ['.kt', '.kts'] },
52
+ dart: { command: ['dart', 'format', '$FILE'], extensions: ['.dart'] },
53
+ terraform: { command: ['terraform', 'fmt', '$FILE'], extensions: ['.tf', '.tfvars'] },
54
+ mix: { command: ['mix', 'format', '$FILE'], extensions: ['.ex', '.exs', '.eex', '.heex', '.leex', '.neex'] },
55
+ gleam: { command: ['gleam', 'format', '$FILE'], extensions: ['.gleam'] },
56
+ zig: { command: ['zig', 'fmt', '$FILE'], extensions: ['.zig', '.zon'] },
57
+ nixfmt: { command: ['nixfmt', '$FILE'], extensions: ['.nix'] },
58
+ ormolu: { command: ['ormolu', '--mode', 'inplace', '$FILE'], extensions: ['.hs'] },
59
+ ocamlformat: { command: ['ocamlformat', '--inplace', '$FILE'], extensions: ['.ml', '.mli'] },
60
+ rubocop: { command: ['rubocop', '-a', '$FILE'], extensions: ['.rb', '.rake', '.gemspec', '.ru'] },
61
+ standardrb: { command: ['standardrb', '--fix', '$FILE'], extensions: ['.rb', '.rake', '.gemspec', '.ru'] },
62
+ htmlbeautifier: { command: ['htmlbeautifier', '$FILE'], extensions: ['.erb'] },
63
+ };
64
+
65
+ /**
66
+ * Resolve the enabled formatter set from config + built-ins.
67
+ * @returns {{enabled: boolean, formatters: Record<string, FormatterDef>}}
68
+ */
69
+ function enabledFormatters() {
70
+ const cfg = loadConfig();
71
+ const f = cfg.formatter;
72
+ if (f === false || f === undefined || f === null) return { enabled: false, formatters: /** @type {Record<string, FormatterDef>} */ ({}) };
73
+ if (f === true) return { enabled: true, formatters: { ...DEFAULT_FORMATTERS } };
74
+ if (f && typeof f === 'object') {
75
+ const out = /** @type {Record<string, FormatterDef>} */ ({});
76
+ for (const [id, def] of Object.entries(DEFAULT_FORMATTERS)) {
77
+ const u = f[id];
78
+ const merged = { command: def.command, extensions: def.extensions, ...(u && typeof u === 'object' ? u : {}) };
79
+ if (merged.disabled) continue;
80
+ out[id] = /** @type {FormatterDef} */ ({ command: merged.command, extensions: merged.extensions });
81
+ }
82
+ // Custom formatters: any object key that isn't built-in with command+extensions.
83
+ for (const [id, u] of Object.entries(f)) {
84
+ if (!u || typeof u !== 'object') continue;
85
+ if (u.command && Array.isArray(u.extensions)) {
86
+ if (!u.disabled) out[id] = /** @type {FormatterDef} */ ({ command: u.command, extensions: u.extensions });
87
+ }
88
+ }
89
+ return { enabled: true, formatters: out };
90
+ }
91
+ return { enabled: false, formatters: /** @type {Record<string, FormatterDef>} */ ({}) };
92
+ }
93
+
94
+ /** Substitute the $FILE placeholder (or append the path) in a command.
95
+ * @param {Array<string>} command
96
+ * @param {string} filePath
97
+ * @returns {Array<string>} */
98
+ function buildCommand(command, filePath) {
99
+ if (command.includes('$FILE')) {
100
+ return command.map((a) => (a === '$FILE' ? filePath : a));
101
+ }
102
+ return command.concat([filePath]);
103
+ }
104
+
105
+ /** Pick the first enabled formatter that handles the given extension.
106
+ * @param {string} ext
107
+ * @returns {{found: boolean, id?: string, command?: Array<string>, reason?: string}} */
108
+ function resolveFormatter(ext) {
109
+ const { enabled, formatters } = enabledFormatters();
110
+ if (!enabled) return { found: false, reason: 'formatters are disabled (set config formatter: true to enable)' };
111
+ const extLower = ext.toLowerCase();
112
+ const ids = Object.keys(formatters).sort();
113
+ for (const id of ids) {
114
+ const def = formatters[id];
115
+ if (def.extensions.some((e) => e.toLowerCase() === extLower)) {
116
+ // Return the raw template — the $FILE placeholder is substituted with the
117
+ // real path in formatFile (resolveFormatter has no path yet).
118
+ return { found: true, id, command: def.command.slice() };
119
+ }
120
+ }
121
+ return { found: false, reason: `no enabled formatter handles extension "${ext}"` };
122
+ }
123
+
124
+ /** Run the file through its formatter in place.
125
+ * @param {string} filePath
126
+ * @returns {Promise<FormatResult>} */
127
+ function formatFile(filePath) {
128
+ const ext = path.extname(filePath);
129
+ const resolved = resolveFormatter(ext);
130
+ if (!resolved.found) return Promise.resolve({ formatted: false, reason: resolved.reason });
131
+ const template = resolved.command;
132
+ const id = resolved.id;
133
+ if (!template || !id) return Promise.resolve({ formatted: false, reason: 'formatter not resolved' });
134
+ const command = buildCommand(template, filePath);
135
+ return new Promise((resolve) => {
136
+ let child;
137
+ try {
138
+ child = spawn(command[0], command.slice(1), {
139
+ stdio: ['ignore', 'pipe', 'pipe'],
140
+ windowsHide: true,
141
+ });
142
+ } catch (e) {
143
+ resolve({ formatted: false, reason: e && e.message ? e.message : String(e) });
144
+ return;
145
+ }
146
+ let stdout = '';
147
+ let stderr = '';
148
+ let settled = false;
149
+ const done = (res) => { if (!settled) { settled = true; clearTimeout(timer); resolve(res); } };
150
+ const timer = setTimeout(() => {
151
+ try { child.kill(); } catch {}
152
+ done({ formatted: false, reason: `${id} timed out after 30000ms` });
153
+ }, 30000);
154
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
155
+ child.stderr.on('data', (d) => { stderr += d.toString(); });
156
+ child.on('error', (err) => {
157
+ const errCode = err && 'code' in err ? err.code : undefined;
158
+ if (errCode === 'ENOENT') {
159
+ done({ formatted: false, reason: `formatter command not found: ${command[0]}. Install it or disable the ${id} formatter.` });
160
+ } else {
161
+ done({ formatted: false, reason: err && err.message ? err.message : String(err) });
162
+ }
163
+ });
164
+ child.on('close', (code) => {
165
+ if (code !== 0) {
166
+ const msg = String(stderr || stdout || '').trim();
167
+ done({ formatted: false, reason: `${id} exited ${code}: ${msg.slice(0, 300)}` });
168
+ return;
169
+ }
170
+ done({ formatted: true, id, command });
171
+ });
172
+ });
173
+ }
174
+
175
+ /** Convenience for the tool layer: format a file just written; on success
176
+ * return a short note to append to the tool result.
177
+ * @param {string} filePath
178
+ * @returns {Promise<string>} */
179
+ async function formatAfterWrite(filePath) {
180
+ if (!fs.existsSync(filePath)) return '';
181
+ const res = await formatFile(filePath);
182
+ return res.formatted ? `\n[formatted by ${res.id}]` : '';
183
+ }
184
+
185
+ /** A names/status summary of the current formatter setup (for /format).
186
+ * @returns {Array<string>} */
187
+ function formatStatusLines() {
188
+ const { enabled, formatters } = enabledFormatters();
189
+ const lines = [`Formatters: ${enabled ? 'ENABLED' : 'DISABLED'} (set config.json "formatter": true to enable)`];
190
+ if (enabled) {
191
+ for (const [id, def] of Object.entries(formatters)) {
192
+ lines.push(` ${id.padEnd(16)} ${def.extensions.join(' ')}`);
193
+ }
194
+ }
195
+ return lines;
196
+ }
197
+
198
+ module.exports = {
199
+ DEFAULT_FORMATTERS,
200
+ enabledFormatters,
201
+ resolveFormatter,
202
+ buildCommand,
203
+ formatFile,
204
+ formatAfterWrite,
205
+ formatStatusLines,
206
+ };