@gettrace/cli 1.4.23 → 1.4.24

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 (2) hide show
  1. package/dist/index.js +2443 -2036
  2. package/package.json +6 -5
package/dist/index.js CHANGED
@@ -1,2123 +1,2530 @@
1
1
  #!/usr/bin/env node
2
- // ============================================
3
- // TRACE CONNECT IDE BRIDGE
4
- // Allows Trace extension to interact with the local filesystem
5
- // ============================================
6
- import { program } from 'commander';
7
- import chalk from 'chalk';
8
- import { WebSocketServer, WebSocket } from 'ws';
9
- import * as fs from 'fs';
10
- import * as path from 'path';
11
- import { exec, spawn } from 'child_process';
12
- import { promisify } from 'util';
13
- import { withFileLock } from './file-lock.js';
14
- import { autoFormat } from './format.js';
15
- import { checkDiagnostics } from './lsp.js';
16
- import { searchCode } from './search.js';
17
- import { editJSXClassName } from './ast.js';
18
- const execAsync = promisify(exec);
19
- // ============================================
20
- // TERMINAL BUFFER
21
- // Rolling 500-line ring buffer of terminal output.
22
- // Shared across all WebSocket clients so every
23
- // connected extension gets the same live stream.
24
- // ============================================
25
- class TerminalBuffer {
26
- lines = [];
27
- MAX_LINES = 500;
28
- push(chunk) {
29
- // Strip ANSI escape codes so agents get clean text
30
- const clean = chunk.replace(/\x1B\[[0-9;]*[mGKHFABCDEJsu]/g, '');
31
- const newLines = clean.split('\n');
32
- for (const line of newLines) {
33
- if (line.trim() === '')
34
- continue;
35
- this.lines.push(line);
36
- if (this.lines.length > this.MAX_LINES) {
37
- this.lines.shift();
38
- }
39
- }
2
+
3
+ // src/index.ts
4
+ import { program } from "commander";
5
+ import chalk from "chalk";
6
+ import { WebSocketServer, WebSocket } from "ws";
7
+ import * as fs4 from "fs";
8
+ import * as path4 from "path";
9
+ import { exec as exec4, spawn } from "child_process";
10
+ import { promisify as promisify3 } from "util";
11
+ import { fileURLToPath } from "url";
12
+
13
+ // src/file-lock.ts
14
+ var _locks = /* @__PURE__ */ new Map();
15
+ async function withFileLock(filePath, fn) {
16
+ const prior = _locks.get(filePath) ?? Promise.resolve();
17
+ let release;
18
+ const hold = new Promise((resolve3) => release = resolve3);
19
+ _locks.set(filePath, hold);
20
+ try {
21
+ await prior;
22
+ return await fn();
23
+ } finally {
24
+ release();
25
+ if (_locks.get(filePath) === hold) {
26
+ _locks.delete(filePath);
40
27
  }
41
- getLast(n = 100) {
42
- return this.lines.slice(-n);
28
+ }
29
+ }
30
+
31
+ // src/format.ts
32
+ import * as fs from "fs";
33
+ import * as path from "path";
34
+ import { exec } from "child_process";
35
+ import { promisify } from "util";
36
+ var execAsync = promisify(exec);
37
+ var JS_EXTS = /* @__PURE__ */ new Set([".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts"]);
38
+ var CSS_EXTS = /* @__PURE__ */ new Set([".css", ".scss", ".less"]);
39
+ var HTML_EXTS = /* @__PURE__ */ new Set([".html", ".htm", ".vue", ".svelte"]);
40
+ var ALL_FORMATTABLE = /* @__PURE__ */ new Set([
41
+ ...JS_EXTS,
42
+ ...CSS_EXTS,
43
+ ...HTML_EXTS,
44
+ ".json",
45
+ ".md",
46
+ ".yaml",
47
+ ".yml"
48
+ ]);
49
+ async function autoFormat(filePath, projectPath) {
50
+ const ext = path.extname(filePath).toLowerCase();
51
+ if (!ALL_FORMATTABLE.has(ext))
52
+ return null;
53
+ const prettierBin = path.join(projectPath, "node_modules", ".bin", "prettier");
54
+ if (fs.existsSync(prettierBin)) {
55
+ try {
56
+ await execAsync(`"${prettierBin}" --write "${filePath}"`, { cwd: projectPath });
57
+ return "prettier";
58
+ } catch {
59
+ }
60
+ }
61
+ if (JS_EXTS.has(ext)) {
62
+ const eslintBin = path.join(projectPath, "node_modules", ".bin", "eslint");
63
+ if (fs.existsSync(eslintBin)) {
64
+ try {
65
+ await execAsync(`"${eslintBin}" --fix "${filePath}"`, { cwd: projectPath });
66
+ return "eslint";
67
+ } catch {
68
+ }
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ // src/lsp.ts
75
+ import * as fs2 from "fs";
76
+ import * as path2 from "path";
77
+ import { exec as exec2 } from "child_process";
78
+ var TS_EXTS = /* @__PURE__ */ new Set([".ts", ".tsx", ".mts", ".cts"]);
79
+ var JS_EXTS2 = /* @__PURE__ */ new Set([".js", ".jsx", ".mjs", ".cjs"]);
80
+ var CSS_EXTS2 = /* @__PURE__ */ new Set([".css", ".scss", ".less", ".sass"]);
81
+ var CHECKER_TIMEOUT_MS = 6e3;
82
+ var MAX_DIAGNOSTICS = 20;
83
+ async function checkDiagnostics(filePath, projectPath) {
84
+ const SKIP = { diagnostics: [], summary: null, checkers: [], ran: false };
85
+ const ext = path2.extname(filePath).toLowerCase();
86
+ const all = [];
87
+ const checkers = [];
88
+ const pending = [];
89
+ if (TS_EXTS.has(ext) || JS_EXTS2.has(ext)) {
90
+ const tsconfig = path2.join(projectPath, "tsconfig.json");
91
+ if (fs2.existsSync(tsconfig)) {
92
+ const shouldRunForJs = JS_EXTS2.has(ext) && tsconfigAllowsJs(tsconfig);
93
+ if (TS_EXTS.has(ext) || shouldRunForJs) {
94
+ checkers.push("tsc");
95
+ pending.push(runTsc(projectPath));
96
+ }
43
97
  }
44
- clear() {
45
- this.lines = [];
98
+ }
99
+ if (TS_EXTS.has(ext) || JS_EXTS2.has(ext)) {
100
+ const eslintBin = path2.join(projectPath, "node_modules", ".bin", "eslint");
101
+ if (fs2.existsSync(eslintBin)) {
102
+ checkers.push("eslint");
103
+ pending.push(runEslint(eslintBin, filePath, projectPath));
46
104
  }
47
- get length() {
48
- return this.lines.length;
105
+ }
106
+ if (CSS_EXTS2.has(ext)) {
107
+ const stylelintBin = path2.join(projectPath, "node_modules", ".bin", "stylelint");
108
+ if (fs2.existsSync(stylelintBin)) {
109
+ checkers.push("stylelint");
110
+ pending.push(runStylelint(stylelintBin, filePath, projectPath));
49
111
  }
112
+ }
113
+ if (pending.length === 0)
114
+ return SKIP;
115
+ const results = await Promise.allSettled(pending);
116
+ for (const r of results) {
117
+ if (r.status === "fulfilled")
118
+ all.push(...r.value);
119
+ }
120
+ const seen = /* @__PURE__ */ new Set();
121
+ const unique = all.filter((d) => {
122
+ const key = `${d.file}:${d.line}:${d.col}:${d.message}`;
123
+ if (seen.has(key))
124
+ return false;
125
+ seen.add(key);
126
+ return true;
127
+ });
128
+ const rel = path2.relative(projectPath, filePath);
129
+ const sorted = [
130
+ ...unique.filter((d) => d.file === rel && d.severity === "error"),
131
+ ...unique.filter((d) => d.file !== rel && d.severity === "error"),
132
+ ...unique.filter((d) => d.severity === "warning")
133
+ ].slice(0, MAX_DIAGNOSTICS);
134
+ const errCount = sorted.filter((d) => d.severity === "error").length;
135
+ const warnCount = sorted.filter((d) => d.severity === "warning").length;
136
+ let summary = null;
137
+ if (errCount > 0) {
138
+ summary = `\u26A0 ${errCount} error${errCount > 1 ? "s" : ""}` + (warnCount > 0 ? ` + ${warnCount} warning${warnCount > 1 ? "s" : ""}` : "") + ` detected after write [${checkers.join("+")}]. Fix these before making more edits.`;
139
+ } else if (warnCount > 0) {
140
+ summary = `\u2139 ${warnCount} warning${warnCount > 1 ? "s" : ""} detected [${checkers.join("+")}]. No errors.`;
141
+ } else if (unique.length === 0 && checkers.length > 0) {
142
+ summary = `\u2713 No errors detected [${checkers.join("+")}].`;
143
+ }
144
+ return { diagnostics: sorted, summary, checkers, ran: true };
50
145
  }
51
- // Singleton shared across all WS connections for `trace dev`
52
- const globalTerminalBuffer = new TerminalBuffer();
53
- let devProcess = null;
54
- // ============================================
55
- // DEV COMMAND DETECTION
56
- // Reads package.json to find the right script
57
- // and detects package manager from lockfiles.
58
- // ============================================
59
- function detectDevCommand(projectPath, override) {
60
- // 1. If user passed an explicit command, use it directly
61
- if (override) {
62
- return { command: override, pm: 'custom', script: override };
146
+ async function runTsc(projectPath) {
147
+ const localTsc = path2.join(projectPath, "node_modules", ".bin", "tsc");
148
+ const bin = fs2.existsSync(localTsc) ? `"${localTsc}"` : "tsc";
149
+ const cmd = `${bin} --noEmit --pretty false`;
150
+ try {
151
+ const raw = await spawnWithTimeout(cmd, projectPath);
152
+ return parseTscOutput(raw, projectPath);
153
+ } catch {
154
+ return [];
155
+ }
156
+ }
157
+ function parseTscOutput(output, projectPath) {
158
+ const diagnostics = [];
159
+ const pattern = /^(.+?)\((\d+),(\d+)\):\s+(error|warning)\s+(TS\d+):\s+(.+)$/gm;
160
+ let m;
161
+ while ((m = pattern.exec(output)) !== null) {
162
+ const [, rawFile, lineStr, colStr, severity, code, message] = m;
163
+ const absFile = path2.isAbsolute(rawFile) ? rawFile : path2.resolve(projectPath, rawFile);
164
+ diagnostics.push({
165
+ file: path2.relative(projectPath, absFile),
166
+ line: parseInt(lineStr, 10),
167
+ col: parseInt(colStr, 10),
168
+ severity,
169
+ code,
170
+ message: message.trim(),
171
+ source: "tsc"
172
+ });
173
+ }
174
+ return diagnostics;
175
+ }
176
+ async function runEslint(eslintBin, filePath, projectPath) {
177
+ const cmd = `"${eslintBin}" --format json "${filePath}"`;
178
+ try {
179
+ const raw = await spawnWithTimeout(cmd, projectPath);
180
+ return parseEslintOutput(raw, projectPath);
181
+ } catch {
182
+ return [];
183
+ }
184
+ }
185
+ function parseEslintOutput(raw, projectPath) {
186
+ const diagnostics = [];
187
+ const jsonStart = raw.indexOf("[");
188
+ if (jsonStart === -1)
189
+ return diagnostics;
190
+ try {
191
+ const results = JSON.parse(raw.slice(jsonStart));
192
+ for (const fileResult of results) {
193
+ const relFile = path2.relative(projectPath, fileResult.filePath);
194
+ for (const msg of fileResult.messages) {
195
+ if (msg.severity === 0)
196
+ continue;
197
+ diagnostics.push({
198
+ file: relFile,
199
+ line: msg.line ?? 1,
200
+ col: msg.column ?? 1,
201
+ severity: msg.severity >= 2 ? "error" : "warning",
202
+ code: msg.ruleId ?? "eslint",
203
+ message: msg.message,
204
+ source: "eslint"
205
+ });
206
+ }
63
207
  }
64
- // 2. Detect package manager from lockfiles
65
- let pm = 'npm';
66
- if (fs.existsSync(path.join(projectPath, 'bun.lockb')))
67
- pm = 'bun';
68
- else if (fs.existsSync(path.join(projectPath, 'pnpm-lock.yaml')))
69
- pm = 'pnpm';
70
- else if (fs.existsSync(path.join(projectPath, 'yarn.lock')))
71
- pm = 'yarn';
72
- // 3. Read package.json scripts
73
- const pkgPath = path.join(projectPath, 'package.json');
74
- let scripts = {};
75
- if (fs.existsSync(pkgPath)) {
76
- try {
77
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
78
- scripts = pkg.scripts || {};
79
- }
80
- catch (e) { /* malformed package.json */ }
208
+ } catch {
209
+ }
210
+ return diagnostics;
211
+ }
212
+ async function runStylelint(stylelintBin, filePath, projectPath) {
213
+ const cmd = `"${stylelintBin}" --formatter json "${filePath}"`;
214
+ try {
215
+ const raw = await spawnWithTimeout(cmd, projectPath);
216
+ return parseStylelintOutput(raw, projectPath);
217
+ } catch {
218
+ return [];
219
+ }
220
+ }
221
+ function parseStylelintOutput(raw, projectPath) {
222
+ const diagnostics = [];
223
+ const jsonStart = raw.indexOf("[");
224
+ if (jsonStart === -1)
225
+ return diagnostics;
226
+ try {
227
+ const results = JSON.parse(raw.slice(jsonStart));
228
+ for (const fileResult of results) {
229
+ const relFile = path2.relative(projectPath, fileResult.source);
230
+ for (const w of fileResult.warnings) {
231
+ diagnostics.push({
232
+ file: relFile,
233
+ line: w.line ?? 1,
234
+ col: w.column ?? 1,
235
+ severity: w.severity === "error" ? "error" : "warning",
236
+ code: w.rule ?? "stylelint",
237
+ message: w.text,
238
+ source: "stylelint"
239
+ });
240
+ }
81
241
  }
82
- // 4. Priority order: dev > start > serve > preview
83
- const candidates = ['dev', 'start', 'serve', 'preview'];
84
- const script = candidates.find(c => !!scripts[c]) || 'dev';
85
- // 5. Build the final command
86
- const runCmd = {
87
- npm: `npm run ${script}`,
88
- pnpm: `pnpm run ${script}`,
89
- yarn: `yarn ${script}`,
90
- bun: `bun run ${script}`,
91
- };
92
- return { command: runCmd[pm] ?? `npm run ${script}`, pm, script };
242
+ } catch {
243
+ }
244
+ return diagnostics;
93
245
  }
94
- // Read version from package.json so it never desyncs with the npm published version.
95
- import { createRequire as _createRequire } from 'module';
96
- const _pkgRequire = _createRequire(import.meta.url);
97
- const VERSION = _pkgRequire('../package.json').version;
98
- /** Levenshtein distance for fuzzy block matching */
99
- function levenshtein(a, b) {
100
- if (a === '' || b === '')
101
- return Math.max(a.length, b.length);
102
- const matrix = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)));
103
- for (let i = 1; i <= a.length; i++) {
104
- for (let j = 1; j <= b.length; j++) {
105
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
106
- matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
107
- }
246
+ function spawnWithTimeout(cmd, cwd) {
247
+ return new Promise((resolve3, reject) => {
248
+ let settled = false;
249
+ const timer = setTimeout(() => {
250
+ if (settled)
251
+ return;
252
+ settled = true;
253
+ proc.kill("SIGTERM");
254
+ reject(new Error(`Checker timed out: ${cmd.slice(0, 60)}`));
255
+ }, CHECKER_TIMEOUT_MS);
256
+ const proc = exec2(cmd, { cwd }, (_err, stdout, stderr) => {
257
+ if (settled)
258
+ return;
259
+ settled = true;
260
+ clearTimeout(timer);
261
+ resolve3(stdout + stderr);
262
+ });
263
+ });
264
+ }
265
+ function tsconfigAllowsJs(tsconfigPath) {
266
+ try {
267
+ const raw = fs2.readFileSync(tsconfigPath, "utf-8");
268
+ return /"allowJs"\s*:\s*true/.test(raw);
269
+ } catch {
270
+ return false;
271
+ }
272
+ }
273
+
274
+ // src/search.ts
275
+ import * as fs3 from "fs";
276
+ import * as path3 from "path";
277
+ import { exec as exec3 } from "child_process";
278
+ import { promisify as promisify2 } from "util";
279
+ var execAsync2 = promisify2(exec3);
280
+ async function searchWithRipgrep(projectPath, query, opts) {
281
+ const flags = [
282
+ "--json",
283
+ "--line-number",
284
+ opts.isRegex ? "" : "--fixed-strings",
285
+ opts.caseSensitive ? "--case-sensitive" : "--ignore-case",
286
+ `--max-count=${opts.maxResults}`,
287
+ // Exclude directories that are never source code
288
+ "--glob",
289
+ "!node_modules/**",
290
+ "--glob",
291
+ "!.git/**",
292
+ "--glob",
293
+ "!dist/**",
294
+ "--glob",
295
+ "!build/**",
296
+ "--glob",
297
+ "!.next/**",
298
+ "--glob",
299
+ "!coverage/**",
300
+ "--glob",
301
+ "!.cache/**",
302
+ "--glob",
303
+ "!.turbo/**"
304
+ ].filter(Boolean);
305
+ const escapedQuery = query.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
306
+ const cmd = `rg ${flags.join(" ")} "${escapedQuery}"`;
307
+ const { stdout } = await execAsync2(cmd, {
308
+ cwd: projectPath,
309
+ maxBuffer: 10 * 1024 * 1024
310
+ // 10 MB — generous for large codebases
311
+ });
312
+ const matches = [];
313
+ for (const line of stdout.split("\n")) {
314
+ const trimmed = line.trim();
315
+ if (!trimmed || !trimmed.startsWith("{"))
316
+ continue;
317
+ try {
318
+ const obj = JSON.parse(trimmed);
319
+ if (obj.type !== "match")
320
+ continue;
321
+ matches.push({
322
+ file: path3.relative(projectPath, obj.data.path.text),
323
+ line: obj.data.line_number,
324
+ content: obj.data.lines.text.trimEnd().substring(0, 200)
325
+ });
326
+ if (matches.length >= opts.maxResults)
327
+ break;
328
+ } catch {
108
329
  }
109
- return matrix[a.length][b.length];
330
+ }
331
+ return matches;
110
332
  }
111
- // --- Replacer 1: Exact string match ---
112
- const SimpleReplacer = function* (_content, find) {
113
- yield find;
114
- };
115
- // --- Replacer 2: Trim whitespace from line ends ---
116
- const LineTrimmedReplacer = function* (content, find) {
117
- const originalLines = content.split('\n');
118
- const searchLines = find.split('\n');
119
- if (searchLines[searchLines.length - 1] === '')
120
- searchLines.pop();
121
- for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
122
- let matches = true;
123
- for (let j = 0; j < searchLines.length; j++) {
124
- if (originalLines[i + j].trim() !== searchLines[j].trim()) {
125
- matches = false;
126
- break;
127
- }
128
- }
129
- if (matches) {
130
- let start = 0;
131
- for (let k = 0; k < i; k++)
132
- start += originalLines[k].length + 1;
133
- let end = start;
134
- for (let k = 0; k < searchLines.length; k++) {
135
- end += originalLines[i + k].length;
136
- if (k < searchLines.length - 1)
137
- end += 1;
138
- }
139
- yield content.substring(start, end);
140
- }
333
+ var FS_SEARCH_EXTENSIONS = /* @__PURE__ */ new Set([
334
+ ".js",
335
+ ".ts",
336
+ ".jsx",
337
+ ".tsx",
338
+ ".vue",
339
+ ".svelte",
340
+ ".css",
341
+ ".scss",
342
+ ".sass",
343
+ ".less",
344
+ ".html",
345
+ ".htm",
346
+ ".json",
347
+ ".md"
348
+ ]);
349
+ var FS_IGNORE_DIRS = /* @__PURE__ */ new Set([
350
+ "node_modules",
351
+ ".git",
352
+ "dist",
353
+ "build",
354
+ ".next",
355
+ ".nuxt",
356
+ ".svelte-kit",
357
+ ".cache",
358
+ "coverage",
359
+ ".turbo",
360
+ ".vercel",
361
+ ".output"
362
+ ]);
363
+ function buildMatcher(query, opts) {
364
+ if (opts.isRegex) {
365
+ try {
366
+ const re = new RegExp(query, opts.caseSensitive ? "" : "i");
367
+ return (line) => re.test(line);
368
+ } catch {
141
369
  }
142
- };
143
- // --- Replacer 3: Block anchor (match first+last lines, fuzzy middle) ---
144
- const BlockAnchorReplacer = function* (content, find) {
145
- const originalLines = content.split('\n');
146
- const searchLines = find.split('\n');
147
- if (searchLines.length < 3)
370
+ }
371
+ if (opts.caseSensitive) {
372
+ return (line) => line.includes(query);
373
+ }
374
+ const lowerQuery = query.toLowerCase();
375
+ return (line) => line.toLowerCase().includes(lowerQuery);
376
+ }
377
+ function searchWithFs(projectPath, query, opts) {
378
+ const results = [];
379
+ const matcher = buildMatcher(query, opts);
380
+ function walkDir(dir) {
381
+ if (results.length >= opts.maxResults)
382
+ return;
383
+ let entries;
384
+ try {
385
+ entries = fs3.readdirSync(dir);
386
+ } catch {
387
+ return;
388
+ }
389
+ for (const entry of entries) {
390
+ if (results.length >= opts.maxResults)
148
391
  return;
149
- if (searchLines[searchLines.length - 1] === '')
150
- searchLines.pop();
151
- const firstSearch = searchLines[0].trim();
152
- const lastSearch = searchLines[searchLines.length - 1].trim();
153
- const searchBlockSize = searchLines.length;
154
- const candidates = [];
155
- for (let i = 0; i < originalLines.length; i++) {
156
- if (originalLines[i].trim() !== firstSearch)
157
- continue;
158
- for (let j = i + 2; j < originalLines.length; j++) {
159
- if (originalLines[j].trim() === lastSearch) {
160
- candidates.push({ startLine: i, endLine: j });
161
- break;
162
- }
392
+ if (FS_IGNORE_DIRS.has(entry) || entry.startsWith("."))
393
+ continue;
394
+ const fullPath = path3.join(dir, entry);
395
+ let stat;
396
+ try {
397
+ stat = fs3.statSync(fullPath);
398
+ } catch {
399
+ continue;
400
+ }
401
+ if (stat.isDirectory()) {
402
+ walkDir(fullPath);
403
+ } else if (FS_SEARCH_EXTENSIONS.has(path3.extname(entry).toLowerCase())) {
404
+ let content;
405
+ try {
406
+ content = fs3.readFileSync(fullPath, "utf-8");
407
+ } catch {
408
+ continue;
409
+ }
410
+ const lines = content.split("\n");
411
+ for (let i = 0; i < lines.length && results.length < opts.maxResults; i++) {
412
+ if (matcher(lines[i])) {
413
+ results.push({
414
+ file: path3.relative(projectPath, fullPath),
415
+ line: i + 1,
416
+ content: lines[i].trim().substring(0, 200)
417
+ });
418
+ }
163
419
  }
420
+ }
164
421
  }
165
- if (candidates.length === 0)
166
- return;
167
- // Pick best candidate by middle-line similarity
168
- let bestMatch = null;
169
- let maxSimilarity = -1;
170
- const threshold = candidates.length === 1 ? 0.0 : 0.3;
171
- for (const cand of candidates) {
172
- const actualSize = cand.endLine - cand.startLine + 1;
173
- let similarity = 0;
174
- const linesToCheck = Math.min(searchBlockSize - 2, actualSize - 2);
175
- if (linesToCheck > 0) {
176
- for (let j = 1; j < searchBlockSize - 1 && j < actualSize - 1; j++) {
177
- const orig = originalLines[cand.startLine + j].trim();
178
- const srch = searchLines[j].trim();
179
- const maxLen = Math.max(orig.length, srch.length);
180
- if (maxLen === 0)
181
- continue;
182
- similarity += (1 - levenshtein(orig, srch) / maxLen) / linesToCheck;
183
- }
422
+ }
423
+ walkDir(projectPath);
424
+ return results;
425
+ }
426
+ async function searchCode(projectPath, query, opts = {}) {
427
+ const resolved = {
428
+ isRegex: opts.isRegex ?? false,
429
+ caseSensitive: opts.caseSensitive ?? true,
430
+ maxResults: opts.maxResults ?? 20
431
+ };
432
+ try {
433
+ const matches = await searchWithRipgrep(projectPath, query, resolved);
434
+ return { matches, engine: "ripgrep" };
435
+ } catch {
436
+ const matches = searchWithFs(projectPath, query, resolved);
437
+ return { matches, engine: "fs" };
438
+ }
439
+ }
440
+
441
+ // src/ast.ts
442
+ import { parse } from "@babel/parser";
443
+ import { createRequire } from "module";
444
+ import * as t from "@babel/types";
445
+ var _require = createRequire(import.meta.url);
446
+ var _traverse = _require("@babel/traverse");
447
+ var _generate = _require("@babel/generator");
448
+ var traverse = _traverse.default ?? _traverse;
449
+ var generate = _generate.default ?? _generate;
450
+ function editJSXClassName(source, edit) {
451
+ let ast;
452
+ try {
453
+ ast = parse(source, {
454
+ sourceType: "module",
455
+ plugins: ["jsx", "typescript"],
456
+ errorRecovery: true
457
+ // tolerate minor syntax errors in source file
458
+ });
459
+ } catch (err) {
460
+ const msg = err instanceof Error ? err.message : String(err);
461
+ return { success: false, error: `Parse error: ${msg}` };
462
+ }
463
+ const candidates = [];
464
+ traverse(ast, {
465
+ JSXOpeningElement(nodePath) {
466
+ for (const attr of nodePath.node.attributes) {
467
+ if (!t.isJSXAttribute(attr))
468
+ continue;
469
+ if (!t.isJSXIdentifier(attr.name, { name: "className" }))
470
+ continue;
471
+ const line = attr.loc?.start.line ?? 0;
472
+ let score = 0;
473
+ if (edit.lineHint && line > 0) {
474
+ const dist = Math.abs(line - edit.lineHint);
475
+ if (dist <= 1)
476
+ score += 100;
477
+ else if (dist <= 5)
478
+ score += 50;
479
+ else if (dist <= 15)
480
+ score += 15;
184
481
  }
185
- else {
186
- similarity = 1.0;
482
+ const currentValue = _extractClassNameString(attr.value);
483
+ if (edit.oldValue) {
484
+ if (currentValue === edit.oldValue)
485
+ score += 80;
486
+ else if (currentValue?.includes(edit.oldValue))
487
+ score += 40;
488
+ else if (currentValue === null && edit.lineHint)
489
+ score += 20;
490
+ } else {
491
+ score += 20;
187
492
  }
188
- if (similarity > maxSimilarity) {
189
- maxSimilarity = similarity;
190
- bestMatch = cand;
493
+ if (score > 0) {
494
+ candidates.push({ nodePath, attrNode: attr, score, line });
191
495
  }
496
+ }
192
497
  }
193
- if (maxSimilarity >= threshold && bestMatch) {
194
- let start = 0;
195
- for (let k = 0; k < bestMatch.startLine; k++)
196
- start += originalLines[k].length + 1;
197
- let end = start;
198
- for (let k = bestMatch.startLine; k <= bestMatch.endLine; k++) {
199
- end += originalLines[k].length;
200
- if (k < bestMatch.endLine)
201
- end += 1;
202
- }
203
- yield content.substring(start, end);
498
+ });
499
+ if (candidates.length === 0) {
500
+ return { success: false, error: "No className attribute found matching the given hints." };
501
+ }
502
+ candidates.sort((a, b) => b.score - a.score);
503
+ const best = candidates[0];
504
+ let strategy;
505
+ if (best.score >= 180)
506
+ strategy = "line_exact";
507
+ else if (best.score >= 100)
508
+ strategy = "line_proximity";
509
+ else if (best.score >= 80)
510
+ strategy = "value_exact";
511
+ else
512
+ strategy = "value_partial";
513
+ best.attrNode.value = t.stringLiteral(edit.newValue);
514
+ const output = generate(
515
+ ast,
516
+ { retainLines: true, jsescOption: { minimal: true } },
517
+ source
518
+ );
519
+ return {
520
+ success: true,
521
+ code: output.code,
522
+ strategy,
523
+ matchedLine: best.line
524
+ };
525
+ }
526
+ function _extractClassNameString(value) {
527
+ if (!value)
528
+ return "";
529
+ if (t.isStringLiteral(value))
530
+ return value.value;
531
+ if (t.isJSXExpressionContainer(value)) {
532
+ const expr = value.expression;
533
+ if (t.isStringLiteral(expr))
534
+ return expr.value;
535
+ if (t.isTemplateLiteral(expr) && expr.expressions.length === 0) {
536
+ return expr.quasis[0]?.value.cooked ?? null;
537
+ }
538
+ }
539
+ return null;
540
+ }
541
+
542
+ // src/index.ts
543
+ import { createRequire as _createRequire } from "module";
544
+ var __filename = fileURLToPath(import.meta.url);
545
+ var __dirname = path4.dirname(__filename);
546
+ var execAsync3 = promisify3(exec4);
547
+ var TerminalBuffer = class {
548
+ lines = [];
549
+ MAX_LINES = 500;
550
+ push(chunk) {
551
+ const clean = chunk.replace(/\x1B\[[0-9;]*[mGKHFABCDEJsu]/g, "");
552
+ const newLines = clean.split("\n");
553
+ for (const line of newLines) {
554
+ if (line.trim() === "")
555
+ continue;
556
+ this.lines.push(line);
557
+ if (this.lines.length > this.MAX_LINES) {
558
+ this.lines.shift();
559
+ }
204
560
  }
561
+ }
562
+ getLast(n = 100) {
563
+ return this.lines.slice(-n);
564
+ }
565
+ clear() {
566
+ this.lines = [];
567
+ }
568
+ get length() {
569
+ return this.lines.length;
570
+ }
205
571
  };
206
- // --- Replacer 4: Normalize all whitespace to single spaces ---
207
- const WhitespaceNormalizedReplacer = function* (content, find) {
208
- const normalize = (text) => text.replace(/\s+/g, ' ').trim();
209
- const normalizedFind = normalize(find);
210
- const lines = content.split('\n');
211
- for (const line of lines) {
212
- if (normalize(line) === normalizedFind) {
213
- yield line;
214
- }
572
+ var globalTerminalBuffer = new TerminalBuffer();
573
+ var devProcess = null;
574
+ function detectDevCommand(projectPath, override) {
575
+ if (override) {
576
+ return { command: override, pm: "custom", script: override };
577
+ }
578
+ let pm = "npm";
579
+ if (fs4.existsSync(path4.join(projectPath, "bun.lockb")))
580
+ pm = "bun";
581
+ else if (fs4.existsSync(path4.join(projectPath, "pnpm-lock.yaml")))
582
+ pm = "pnpm";
583
+ else if (fs4.existsSync(path4.join(projectPath, "yarn.lock")))
584
+ pm = "yarn";
585
+ const pkgPath = path4.join(projectPath, "package.json");
586
+ let scripts = {};
587
+ if (fs4.existsSync(pkgPath)) {
588
+ try {
589
+ const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf-8"));
590
+ scripts = pkg.scripts || {};
591
+ } catch (e) {
215
592
  }
216
- const findLines = find.split('\n');
217
- if (findLines.length > 1) {
218
- for (let i = 0; i <= lines.length - findLines.length; i++) {
219
- const block = lines.slice(i, i + findLines.length);
220
- if (normalize(block.join('\n')) === normalizedFind) {
221
- yield block.join('\n');
222
- }
223
- }
593
+ }
594
+ const candidates = ["dev", "start", "serve", "preview"];
595
+ const script = candidates.find((c) => !!scripts[c]) || "dev";
596
+ const runCmd = {
597
+ npm: `npm run ${script}`,
598
+ pnpm: `pnpm run ${script}`,
599
+ yarn: `yarn ${script}`,
600
+ bun: `bun run ${script}`
601
+ };
602
+ return { command: runCmd[pm] ?? `npm run ${script}`, pm, script };
603
+ }
604
+ var _pkgRequire = _createRequire(import.meta.url);
605
+ var VERSION = _pkgRequire("../package.json").version;
606
+ function levenshtein(a, b) {
607
+ if (a === "" || b === "")
608
+ return Math.max(a.length, b.length);
609
+ const matrix = Array.from(
610
+ { length: a.length + 1 },
611
+ (_, i) => Array.from({ length: b.length + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0)
612
+ );
613
+ for (let i = 1; i <= a.length; i++) {
614
+ for (let j = 1; j <= b.length; j++) {
615
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
616
+ matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost);
224
617
  }
618
+ }
619
+ return matrix[a.length][b.length];
620
+ }
621
+ var SimpleReplacer = function* (_content, find) {
622
+ yield find;
225
623
  };
226
- // --- Replacer 5: Strip indentation then compare ---
227
- const IndentationFlexibleReplacer = function* (content, find) {
228
- const removeIndent = (text) => {
229
- const lines = text.split('\n');
230
- const nonEmpty = lines.filter(l => l.trim().length > 0);
231
- if (nonEmpty.length === 0)
232
- return text;
233
- const minIndent = Math.min(...nonEmpty.map(l => { const m = l.match(/^(\s*)/); return m ? m[1].length : 0; }));
234
- return lines.map(l => l.trim().length === 0 ? l : l.slice(minIndent)).join('\n');
235
- };
236
- const normalizedFind = removeIndent(find);
237
- const contentLines = content.split('\n');
238
- const findLines = find.split('\n');
239
- for (let i = 0; i <= contentLines.length - findLines.length; i++) {
240
- const block = contentLines.slice(i, i + findLines.length).join('\n');
241
- if (removeIndent(block) === normalizedFind) {
242
- yield block;
243
- }
624
+ var LineTrimmedReplacer = function* (content, find) {
625
+ const originalLines = content.split("\n");
626
+ const searchLines = find.split("\n");
627
+ if (searchLines[searchLines.length - 1] === "")
628
+ searchLines.pop();
629
+ for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
630
+ let matches = true;
631
+ for (let j = 0; j < searchLines.length; j++) {
632
+ if (originalLines[i + j].trim() !== searchLines[j].trim()) {
633
+ matches = false;
634
+ break;
635
+ }
244
636
  }
637
+ if (matches) {
638
+ let start = 0;
639
+ for (let k = 0; k < i; k++)
640
+ start += originalLines[k].length + 1;
641
+ let end = start;
642
+ for (let k = 0; k < searchLines.length; k++) {
643
+ end += originalLines[i + k].length;
644
+ if (k < searchLines.length - 1)
645
+ end += 1;
646
+ }
647
+ yield content.substring(start, end);
648
+ }
649
+ }
245
650
  };
246
- // --- Replacer 6: Normalize escape sequences ---
247
- const EscapeNormalizedReplacer = function* (content, find) {
248
- const unescape = (str) => str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, c) => {
249
- switch (c) {
250
- case 'n': return '\n';
251
- case 't': return '\t';
252
- case 'r': return '\r';
253
- case "'": return "'";
254
- case '"': return '"';
255
- case '`': return '`';
256
- case '\\': return '\\';
257
- case '\n': return '\n';
258
- case '$': return '$';
259
- default: return match;
260
- }
261
- });
262
- const unescapedFind = unescape(find);
263
- if (content.includes(unescapedFind))
264
- yield unescapedFind;
265
- const lines = content.split('\n');
266
- const findLines = unescapedFind.split('\n');
267
- for (let i = 0; i <= lines.length - findLines.length; i++) {
268
- const block = lines.slice(i, i + findLines.length).join('\n');
269
- if (unescape(block) === unescapedFind)
270
- yield block;
651
+ var BlockAnchorReplacer = function* (content, find) {
652
+ const originalLines = content.split("\n");
653
+ const searchLines = find.split("\n");
654
+ if (searchLines.length < 3)
655
+ return;
656
+ if (searchLines[searchLines.length - 1] === "")
657
+ searchLines.pop();
658
+ const firstSearch = searchLines[0].trim();
659
+ const lastSearch = searchLines[searchLines.length - 1].trim();
660
+ const searchBlockSize = searchLines.length;
661
+ const candidates = [];
662
+ for (let i = 0; i < originalLines.length; i++) {
663
+ if (originalLines[i].trim() !== firstSearch)
664
+ continue;
665
+ for (let j = i + 2; j < originalLines.length; j++) {
666
+ if (originalLines[j].trim() === lastSearch) {
667
+ candidates.push({ startLine: i, endLine: j });
668
+ break;
669
+ }
670
+ }
671
+ }
672
+ if (candidates.length === 0)
673
+ return;
674
+ let bestMatch = null;
675
+ let maxSimilarity = -1;
676
+ const threshold = candidates.length === 1 ? 0 : 0.3;
677
+ for (const cand of candidates) {
678
+ const actualSize = cand.endLine - cand.startLine + 1;
679
+ let similarity = 0;
680
+ const linesToCheck = Math.min(searchBlockSize - 2, actualSize - 2);
681
+ if (linesToCheck > 0) {
682
+ for (let j = 1; j < searchBlockSize - 1 && j < actualSize - 1; j++) {
683
+ const orig = originalLines[cand.startLine + j].trim();
684
+ const srch = searchLines[j].trim();
685
+ const maxLen = Math.max(orig.length, srch.length);
686
+ if (maxLen === 0)
687
+ continue;
688
+ similarity += (1 - levenshtein(orig, srch) / maxLen) / linesToCheck;
689
+ }
690
+ } else {
691
+ similarity = 1;
692
+ }
693
+ if (similarity > maxSimilarity) {
694
+ maxSimilarity = similarity;
695
+ bestMatch = cand;
271
696
  }
697
+ }
698
+ if (maxSimilarity >= threshold && bestMatch) {
699
+ let start = 0;
700
+ for (let k = 0; k < bestMatch.startLine; k++)
701
+ start += originalLines[k].length + 1;
702
+ let end = start;
703
+ for (let k = bestMatch.startLine; k <= bestMatch.endLine; k++) {
704
+ end += originalLines[k].length;
705
+ if (k < bestMatch.endLine)
706
+ end += 1;
707
+ }
708
+ yield content.substring(start, end);
709
+ }
272
710
  };
273
- // --- Replacer 7: Trim the entire block boundary ---
274
- const TrimmedBoundaryReplacer = function* (content, find) {
275
- const trimmed = find.trim();
276
- if (trimmed === find)
277
- return;
278
- if (content.includes(trimmed))
279
- yield trimmed;
280
- const lines = content.split('\n');
281
- const findLines = find.split('\n');
711
+ var WhitespaceNormalizedReplacer = function* (content, find) {
712
+ const normalize = (text) => text.replace(/\s+/g, " ").trim();
713
+ const normalizedFind = normalize(find);
714
+ const lines = content.split("\n");
715
+ for (const line of lines) {
716
+ if (normalize(line) === normalizedFind) {
717
+ yield line;
718
+ }
719
+ }
720
+ const findLines = find.split("\n");
721
+ if (findLines.length > 1) {
282
722
  for (let i = 0; i <= lines.length - findLines.length; i++) {
283
- const block = lines.slice(i, i + findLines.length).join('\n');
284
- if (block.trim() === trimmed)
285
- yield block;
723
+ const block = lines.slice(i, i + findLines.length);
724
+ if (normalize(block.join("\n")) === normalizedFind) {
725
+ yield block.join("\n");
726
+ }
286
727
  }
728
+ }
287
729
  };
288
- // --- Replacer 8: Context-aware (first+last anchors, 50% middle similarity) ---
289
- const ContextAwareReplacer = function* (content, find) {
290
- const findLines = find.split('\n');
291
- if (findLines.length < 3)
292
- return;
293
- if (findLines[findLines.length - 1] === '')
294
- findLines.pop();
295
- const contentLines = content.split('\n');
296
- const firstLine = findLines[0].trim();
297
- const lastLine = findLines[findLines.length - 1].trim();
298
- for (let i = 0; i < contentLines.length; i++) {
299
- if (contentLines[i].trim() !== firstLine)
300
- continue;
301
- for (let j = i + 2; j < contentLines.length; j++) {
302
- if (contentLines[j].trim() === lastLine) {
303
- const blockLines = contentLines.slice(i, j + 1);
304
- if (blockLines.length === findLines.length) {
305
- let matching = 0, total = 0;
306
- for (let k = 1; k < blockLines.length - 1; k++) {
307
- const bl = blockLines[k].trim(), fl = findLines[k].trim();
308
- if (bl.length > 0 || fl.length > 0) {
309
- total++;
310
- if (bl === fl)
311
- matching++;
312
- }
313
- }
314
- if (total === 0 || matching / total >= 0.5) {
315
- yield blockLines.join('\n');
316
- return;
317
- }
318
- }
319
- break;
320
- }
321
- }
730
+ var IndentationFlexibleReplacer = function* (content, find) {
731
+ const removeIndent = (text) => {
732
+ const lines = text.split("\n");
733
+ const nonEmpty = lines.filter((l) => l.trim().length > 0);
734
+ if (nonEmpty.length === 0)
735
+ return text;
736
+ const minIndent = Math.min(...nonEmpty.map((l) => {
737
+ const m = l.match(/^(\s*)/);
738
+ return m ? m[1].length : 0;
739
+ }));
740
+ return lines.map((l) => l.trim().length === 0 ? l : l.slice(minIndent)).join("\n");
741
+ };
742
+ const normalizedFind = removeIndent(find);
743
+ const contentLines = content.split("\n");
744
+ const findLines = find.split("\n");
745
+ for (let i = 0; i <= contentLines.length - findLines.length; i++) {
746
+ const block = contentLines.slice(i, i + findLines.length).join("\n");
747
+ if (removeIndent(block) === normalizedFind) {
748
+ yield block;
322
749
  }
750
+ }
323
751
  };
324
- // --- Replacer 9: Multi-occurrence finder ---
325
- const MultiOccurrenceReplacer = function* (content, find) {
326
- let start = 0;
327
- while (true) {
328
- const idx = content.indexOf(find, start);
329
- if (idx === -1)
330
- break;
331
- yield find;
332
- start = idx + find.length;
752
+ var EscapeNormalizedReplacer = function* (content, find) {
753
+ const unescape = (str) => str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, c) => {
754
+ switch (c) {
755
+ case "n":
756
+ return "\n";
757
+ case "t":
758
+ return " ";
759
+ case "r":
760
+ return "\r";
761
+ case "'":
762
+ return "'";
763
+ case '"':
764
+ return '"';
765
+ case "`":
766
+ return "`";
767
+ case "\\":
768
+ return "\\";
769
+ case "\n":
770
+ return "\n";
771
+ case "$":
772
+ return "$";
773
+ default:
774
+ return match;
333
775
  }
776
+ });
777
+ const unescapedFind = unescape(find);
778
+ if (content.includes(unescapedFind))
779
+ yield unescapedFind;
780
+ const lines = content.split("\n");
781
+ const findLines = unescapedFind.split("\n");
782
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
783
+ const block = lines.slice(i, i + findLines.length).join("\n");
784
+ if (unescape(block) === unescapedFind)
785
+ yield block;
786
+ }
334
787
  };
335
- // --- Replacer 10: Blank-line tolerant (collapses empty lines before comparing) ---
336
- const BlankLineTolerantReplacer = function* (content, find) {
337
- // Collapse consecutive blank lines into a single \n for comparison
338
- const collapseBlankLines = (text) => text.replace(/\n\s*\n/g, '\n').trim();
339
- const collapsedFind = collapseBlankLines(find);
340
- if (!collapsedFind)
341
- return;
342
- // Try to find the collapsed version directly
343
- const collapsedContent = collapseBlankLines(content);
344
- if (collapsedContent.includes(collapsedFind)) {
345
- // We need to find the ORIGINAL substring in content that matches
346
- // Walk through content lines and find the region
347
- const contentLines = content.split('\n');
348
- const findLines = find.split('\n').filter(l => l.trim().length > 0);
349
- if (findLines.length === 0)
350
- return;
351
- const firstNonEmpty = findLines[0].trim();
352
- const lastNonEmpty = findLines[findLines.length - 1].trim();
353
- for (let i = 0; i < contentLines.length; i++) {
354
- if (contentLines[i].trim() !== firstNonEmpty)
355
- continue;
356
- // Found first line, now scan forward to find last line
357
- // allowing blank lines in between
358
- let foundEnd = -1;
359
- let nonEmptyCount = 0;
360
- for (let j = i; j < contentLines.length; j++) {
361
- if (contentLines[j].trim().length > 0) {
362
- nonEmptyCount++;
363
- }
364
- if (contentLines[j].trim() === lastNonEmpty && nonEmptyCount >= findLines.length) {
365
- foundEnd = j;
366
- break;
367
- }
368
- // Don't scan too far (max 2x the expected size)
369
- if (j - i > findLines.length * 2 + 5)
370
- break;
371
- }
372
- if (foundEnd >= 0) {
373
- // Verify the non-empty lines match
374
- const candidateLines = contentLines.slice(i, foundEnd + 1);
375
- const candidateNonEmpty = candidateLines.filter(l => l.trim().length > 0);
376
- let allMatch = candidateNonEmpty.length === findLines.length;
377
- if (allMatch) {
378
- for (let m = 0; m < findLines.length; m++) {
379
- if (candidateNonEmpty[m].trim() !== findLines[m].trim()) {
380
- allMatch = false;
381
- break;
382
- }
383
- }
384
- }
385
- if (allMatch) {
386
- yield candidateLines.join('\n');
387
- return;
388
- }
788
+ var TrimmedBoundaryReplacer = function* (content, find) {
789
+ const trimmed = find.trim();
790
+ if (trimmed === find)
791
+ return;
792
+ if (content.includes(trimmed))
793
+ yield trimmed;
794
+ const lines = content.split("\n");
795
+ const findLines = find.split("\n");
796
+ for (let i = 0; i <= lines.length - findLines.length; i++) {
797
+ const block = lines.slice(i, i + findLines.length).join("\n");
798
+ if (block.trim() === trimmed)
799
+ yield block;
800
+ }
801
+ };
802
+ var ContextAwareReplacer = function* (content, find) {
803
+ const findLines = find.split("\n");
804
+ if (findLines.length < 3)
805
+ return;
806
+ if (findLines[findLines.length - 1] === "")
807
+ findLines.pop();
808
+ const contentLines = content.split("\n");
809
+ const firstLine = findLines[0].trim();
810
+ const lastLine = findLines[findLines.length - 1].trim();
811
+ for (let i = 0; i < contentLines.length; i++) {
812
+ if (contentLines[i].trim() !== firstLine)
813
+ continue;
814
+ for (let j = i + 2; j < contentLines.length; j++) {
815
+ if (contentLines[j].trim() === lastLine) {
816
+ const blockLines = contentLines.slice(i, j + 1);
817
+ if (blockLines.length === findLines.length) {
818
+ let matching = 0, total = 0;
819
+ for (let k = 1; k < blockLines.length - 1; k++) {
820
+ const bl = blockLines[k].trim(), fl = findLines[k].trim();
821
+ if (bl.length > 0 || fl.length > 0) {
822
+ total++;
823
+ if (bl === fl)
824
+ matching++;
389
825
  }
826
+ }
827
+ if (total === 0 || matching / total >= 0.5) {
828
+ yield blockLines.join("\n");
829
+ return;
830
+ }
390
831
  }
832
+ break;
833
+ }
391
834
  }
835
+ }
392
836
  };
393
- /** Master replace function: cascades through all 10 replacers */
394
- function fuzzyReplace(content, oldString, newString, replaceAll = false) {
395
- if (oldString === newString) {
396
- return { error: 'No changes: oldString and newString are identical.' };
397
- }
398
- const replacers = [
399
- ['exact', SimpleReplacer],
400
- ['line-trimmed', LineTrimmedReplacer],
401
- ['block-anchor', BlockAnchorReplacer],
402
- ['whitespace-normalized', WhitespaceNormalizedReplacer],
403
- ['indentation-flexible', IndentationFlexibleReplacer],
404
- ['escape-normalized', EscapeNormalizedReplacer],
405
- ['trimmed-boundary', TrimmedBoundaryReplacer],
406
- ['context-aware', ContextAwareReplacer],
407
- ['multi-occurrence', MultiOccurrenceReplacer],
408
- ['blank-line-tolerant', BlankLineTolerantReplacer],
409
- ];
410
- let notFound = true;
411
- for (const [name, replacer] of replacers) {
412
- for (const search of replacer(content, oldString)) {
413
- const index = content.indexOf(search);
414
- if (index === -1)
415
- continue;
416
- notFound = false;
417
- if (replaceAll) {
418
- return { result: content.replaceAll(search, newString), strategy: name };
837
+ var MultiOccurrenceReplacer = function* (content, find) {
838
+ let start = 0;
839
+ while (true) {
840
+ const idx = content.indexOf(find, start);
841
+ if (idx === -1)
842
+ break;
843
+ yield find;
844
+ start = idx + find.length;
845
+ }
846
+ };
847
+ var BlankLineTolerantReplacer = function* (content, find) {
848
+ const collapseBlankLines = (text) => text.replace(/\n\s*\n/g, "\n").trim();
849
+ const collapsedFind = collapseBlankLines(find);
850
+ if (!collapsedFind)
851
+ return;
852
+ const collapsedContent = collapseBlankLines(content);
853
+ if (collapsedContent.includes(collapsedFind)) {
854
+ const contentLines = content.split("\n");
855
+ const findLines = find.split("\n").filter((l) => l.trim().length > 0);
856
+ if (findLines.length === 0)
857
+ return;
858
+ const firstNonEmpty = findLines[0].trim();
859
+ const lastNonEmpty = findLines[findLines.length - 1].trim();
860
+ for (let i = 0; i < contentLines.length; i++) {
861
+ if (contentLines[i].trim() !== firstNonEmpty)
862
+ continue;
863
+ let foundEnd = -1;
864
+ let nonEmptyCount = 0;
865
+ for (let j = i; j < contentLines.length; j++) {
866
+ if (contentLines[j].trim().length > 0) {
867
+ nonEmptyCount++;
868
+ }
869
+ if (contentLines[j].trim() === lastNonEmpty && nonEmptyCount >= findLines.length) {
870
+ foundEnd = j;
871
+ break;
872
+ }
873
+ if (j - i > findLines.length * 2 + 5)
874
+ break;
875
+ }
876
+ if (foundEnd >= 0) {
877
+ const candidateLines = contentLines.slice(i, foundEnd + 1);
878
+ const candidateNonEmpty = candidateLines.filter((l) => l.trim().length > 0);
879
+ let allMatch = candidateNonEmpty.length === findLines.length;
880
+ if (allMatch) {
881
+ for (let m = 0; m < findLines.length; m++) {
882
+ if (candidateNonEmpty[m].trim() !== findLines[m].trim()) {
883
+ allMatch = false;
884
+ break;
419
885
  }
420
- const lastIndex = content.lastIndexOf(search);
421
- if (index !== lastIndex)
422
- continue; // Multiple matches, try next replacer
423
- return {
424
- result: content.substring(0, index) + newString + content.substring(index + search.length),
425
- strategy: name,
426
- };
886
+ }
427
887
  }
888
+ if (allMatch) {
889
+ yield candidateLines.join("\n");
890
+ return;
891
+ }
892
+ }
428
893
  }
429
- if (notFound) {
430
- return { error: 'Could not find oldString in the file. Tried 10 matching strategies including fuzzy whitespace, indentation, blank-line-tolerant, and block-anchor matching.' };
894
+ }
895
+ };
896
+ function fuzzyReplace(content, oldString, newString, replaceAll = false) {
897
+ if (oldString === newString) {
898
+ return { error: "No changes: oldString and newString are identical." };
899
+ }
900
+ const replacers = [
901
+ ["exact", SimpleReplacer],
902
+ ["line-trimmed", LineTrimmedReplacer],
903
+ ["block-anchor", BlockAnchorReplacer],
904
+ ["whitespace-normalized", WhitespaceNormalizedReplacer],
905
+ ["indentation-flexible", IndentationFlexibleReplacer],
906
+ ["escape-normalized", EscapeNormalizedReplacer],
907
+ ["trimmed-boundary", TrimmedBoundaryReplacer],
908
+ ["context-aware", ContextAwareReplacer],
909
+ ["multi-occurrence", MultiOccurrenceReplacer],
910
+ ["blank-line-tolerant", BlankLineTolerantReplacer]
911
+ ];
912
+ let notFound = true;
913
+ for (const [name, replacer] of replacers) {
914
+ for (const search of replacer(content, oldString)) {
915
+ const index = content.indexOf(search);
916
+ if (index === -1)
917
+ continue;
918
+ notFound = false;
919
+ if (replaceAll) {
920
+ return { result: content.replaceAll(search, newString), strategy: name };
921
+ }
922
+ const lastIndex = content.lastIndexOf(search);
923
+ if (index !== lastIndex)
924
+ continue;
925
+ return {
926
+ result: content.substring(0, index) + newString + content.substring(index + search.length),
927
+ strategy: name
928
+ };
431
929
  }
432
- return { error: 'Found multiple matches for oldString. Provide more surrounding context to make the match unique.' };
930
+ }
931
+ if (notFound) {
932
+ return { error: "Could not find oldString in the file. Tried 10 matching strategies including fuzzy whitespace, indentation, blank-line-tolerant, and block-anchor matching." };
933
+ }
934
+ return { error: "Found multiple matches for oldString. Provide more surrounding context to make the match unique." };
433
935
  }
434
- // ============================================
435
- // AUTO-FORMAT imported from ./format
436
- // Cascade: Prettier → ESLint --fix
437
- // (function body lives in src/format.ts)
438
- // ============================================
439
- program
440
- .name('trace')
441
- .description('Trace IDE Bridge — connect your codebase and dev server to the Trace extension')
442
- .version(VERSION);
443
- // ============================================
444
- // COMMAND: trace dev
445
- // Starts your dev server AND the IDE bridge in
446
- // one command. Terminal output is streamed to
447
- // the extension so Trace agents have live context.
448
- // ============================================
449
- program
450
- .command('dev')
451
- .description('Start dev server + IDE bridge together (recommended)')
452
- .argument('[command]', 'Override the dev command (e.g. "npm run start:staging")')
453
- .option('-p, --port <port>', 'WebSocket port for IDE bridge', '8765')
454
- .action(async (commandOverride, options) => {
455
- const port = parseInt(options.port);
456
- const projectPath = process.cwd();
457
- const { command, pm, script } = detectDevCommand(projectPath, commandOverride);
458
- console.log();
459
- console.log(chalk.bold.cyan('⚡ Trace Dev'));
460
- console.log(chalk.gray('─'.repeat(55)));
461
- console.log();
462
- console.log(`📁 Project: ${chalk.green(projectPath)}`);
463
- console.log(`📦 Package Mgr: ${chalk.yellow(pm)}`);
464
- console.log(`🚀 Dev Command: ${chalk.cyan(command)}`);
465
- console.log(`🌐 Bridge Port: ${chalk.cyan(port)}`);
936
+ program.name("trace").description("Trace IDE Bridge \u2014 connect your codebase and dev server to the Trace extension").version(VERSION);
937
+ program.command("dev").description("Start dev server + IDE bridge together (recommended)").argument("[command]", 'Override the dev command (e.g. "npm run start:staging")').option("-p, --port <port>", "WebSocket port for IDE bridge", "8765").action(async (commandOverride, options) => {
938
+ const port = parseInt(options.port);
939
+ const projectPath = process.cwd();
940
+ const { command, pm, script } = detectDevCommand(projectPath, commandOverride);
941
+ console.log();
942
+ console.log(chalk.bold.cyan("\u26A1 Trace Dev"));
943
+ console.log(chalk.gray("\u2500".repeat(55)));
944
+ console.log();
945
+ console.log(`\u{1F4C1} Project: ${chalk.green(projectPath)}`);
946
+ console.log(`\u{1F4E6} Package Mgr: ${chalk.yellow(pm)}`);
947
+ console.log(`\u{1F680} Dev Command: ${chalk.cyan(command)}`);
948
+ console.log(`\u{1F310} Bridge Port: ${chalk.cyan(port)}`);
949
+ console.log();
950
+ console.log(chalk.gray("\u2500".repeat(55)));
951
+ console.log();
952
+ const connectedClients = /* @__PURE__ */ new Set();
953
+ const broadcast = (payload) => {
954
+ const msg = JSON.stringify(payload);
955
+ for (const client of connectedClients) {
956
+ if (client.readyState === WebSocket.OPEN) {
957
+ client.send(msg);
958
+ }
959
+ }
960
+ };
961
+ console.log(chalk.dim("Starting dev server..."));
962
+ console.log();
963
+ const childEnv = { ...process.env, FORCE_COLOR: "1" };
964
+ delete childEnv.MallocNanoZone;
965
+ delete childEnv.MallocStackLogging;
966
+ delete childEnv.MallocScribble;
967
+ delete childEnv.MallocGuardEdges;
968
+ delete childEnv.MallocErrorAbort;
969
+ devProcess = spawn(command, [], {
970
+ cwd: projectPath,
971
+ shell: true,
972
+ env: childEnv
973
+ });
974
+ devProcess.stdout?.on("data", (chunk) => {
975
+ const text = chunk.toString();
976
+ process.stdout.write(text);
977
+ globalTerminalBuffer.push(text);
978
+ broadcast({ type: "STREAM_CHUNK", stream: "stdout", chunk: text });
979
+ });
980
+ devProcess.stderr?.on("data", (chunk) => {
981
+ const text = chunk.toString();
982
+ process.stderr.write(text);
983
+ globalTerminalBuffer.push(text);
984
+ broadcast({ type: "STREAM_CHUNK", stream: "stderr", chunk: text });
985
+ });
986
+ devProcess.on("close", (code) => {
987
+ const msg = `
988
+ [Trace] Dev server exited with code ${code}
989
+ `;
990
+ process.stdout.write(chalk.yellow(msg));
991
+ globalTerminalBuffer.push(msg);
992
+ broadcast({ type: "STREAM_END", exitCode: code });
993
+ devProcess = null;
994
+ });
995
+ devProcess.on("error", (err) => {
996
+ const msg = `[Trace] Failed to start dev server: ${err.message}
997
+ `;
998
+ process.stderr.write(chalk.red(msg));
999
+ console.error(chalk.red("\n\u2717 Could not start dev server."));
1000
+ console.error(chalk.dim(` Command: ${command}`));
1001
+ console.error(chalk.dim(` Make sure the script exists in your package.json`));
1002
+ });
1003
+ const wss = new WebSocketServer({ port });
1004
+ let clientCount = 0;
1005
+ wss.on("listening", () => {
466
1006
  console.log();
467
- console.log(chalk.gray('─'.repeat(55)));
1007
+ console.log(chalk.gray("\u2500".repeat(55)));
1008
+ console.log(chalk.green("\u2713") + " IDE Bridge listening on port " + chalk.cyan(port));
1009
+ console.log(chalk.dim("Waiting for Trace extension to connect..."));
1010
+ console.log(chalk.dim("Press Ctrl+C to stop both"));
1011
+ console.log(chalk.gray("\u2500".repeat(55)));
468
1012
  console.log();
469
- // Track all connected WebSocket clients so we can push STREAM_CHUNK events
470
- const connectedClients = new Set();
471
- const broadcast = (payload) => {
472
- const msg = JSON.stringify(payload);
473
- for (const client of connectedClients) {
474
- if (client.readyState === WebSocket.OPEN) {
475
- client.send(msg);
476
- }
477
- }
478
- };
479
- // ── Start the dev server ──────────────────────────────────
480
- console.log(chalk.dim('Starting dev server...'));
481
- console.log();
482
- devProcess = spawn(command, [], {
483
- cwd: projectPath,
484
- shell: true,
485
- env: { ...process.env, FORCE_COLOR: '1' },
486
- });
487
- devProcess.stdout?.on('data', (chunk) => {
488
- const text = chunk.toString();
489
- // Print to terminal so the developer still sees their logs
490
- process.stdout.write(text);
491
- // Store in rolling buffer (ANSI stripped)
492
- globalTerminalBuffer.push(text);
493
- // Push raw chunk to extension for live terminal panel
494
- broadcast({ type: 'STREAM_CHUNK', stream: 'stdout', chunk: text });
495
- });
496
- devProcess.stderr?.on('data', (chunk) => {
497
- const text = chunk.toString();
498
- process.stderr.write(text);
499
- globalTerminalBuffer.push(text);
500
- broadcast({ type: 'STREAM_CHUNK', stream: 'stderr', chunk: text });
501
- });
502
- devProcess.on('close', (code) => {
503
- const msg = `\n[Trace] Dev server exited with code ${code}\n`;
504
- process.stdout.write(chalk.yellow(msg));
505
- globalTerminalBuffer.push(msg);
506
- broadcast({ type: 'STREAM_END', exitCode: code });
507
- devProcess = null;
508
- });
509
- devProcess.on('error', (err) => {
510
- const msg = `[Trace] Failed to start dev server: ${err.message}\n`;
511
- process.stderr.write(chalk.red(msg));
512
- console.error(chalk.red('\n✗ Could not start dev server.'));
513
- console.error(chalk.dim(` Command: ${command}`));
514
- console.error(chalk.dim(` Make sure the script exists in your package.json`));
1013
+ });
1014
+ wss.on("connection", (ws) => {
1015
+ clientCount++;
1016
+ connectedClients.add(ws);
1017
+ console.log(chalk.green("\u25CF") + ` Extension connected (${clientCount} client${clientCount > 1 ? "s" : ""})`);
1018
+ attachMessageHandler(ws, projectPath);
1019
+ const catchup = globalTerminalBuffer.getLast(100);
1020
+ if (catchup.length > 0) {
1021
+ ws.send(JSON.stringify({ type: "TERMINAL_CATCHUP", lines: catchup }));
1022
+ }
1023
+ ws.on("close", () => {
1024
+ clientCount--;
1025
+ connectedClients.delete(ws);
1026
+ console.log(chalk.yellow("\u25CF") + ` Extension disconnected (${clientCount} client${clientCount > 1 ? "s" : ""})`);
515
1027
  });
516
- // ── Start the WebSocket IDE bridge ────────────────────────
517
- const wss = new WebSocketServer({ port });
518
- let clientCount = 0;
519
- wss.on('listening', () => {
520
- console.log();
521
- console.log(chalk.gray('─'.repeat(55)));
522
- console.log(chalk.green('✓') + ' IDE Bridge listening on port ' + chalk.cyan(port));
523
- console.log(chalk.dim('Waiting for Trace extension to connect...'));
524
- console.log(chalk.dim('Press Ctrl+C to stop both'));
525
- console.log(chalk.gray('─'.repeat(55)));
526
- console.log();
1028
+ ws.on("error", (err) => {
1029
+ console.error(chalk.red("WebSocket error:"), err.message);
1030
+ connectedClients.delete(ws);
527
1031
  });
528
- wss.on('connection', (ws) => {
529
- clientCount++;
530
- connectedClients.add(ws);
531
- console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
532
- // Wire up the full IDE bridge protocol (file read/write, detect project, etc.)
533
- attachMessageHandler(ws, projectPath);
534
- // Send the last 100 lines immediately so the extension has terminal context
535
- const catchup = globalTerminalBuffer.getLast(100);
536
- if (catchup.length > 0) {
537
- ws.send(JSON.stringify({ type: 'TERMINAL_CATCHUP', lines: catchup }));
1032
+ });
1033
+ wss.on("error", (error) => {
1034
+ if (error.code === "EADDRINUSE") {
1035
+ console.error(chalk.red(`\u2717 Port ${port} is already in use. Try: trace dev --port 8766`));
1036
+ } else {
1037
+ console.error(chalk.red("Bridge error:"), error.message);
1038
+ }
1039
+ });
1040
+ const http = await import("http");
1041
+ const browserHttpServer = http.createServer(async (req, res) => {
1042
+ res.setHeader("Access-Control-Allow-Origin", "*");
1043
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
1044
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
1045
+ if (req.method === "OPTIONS") {
1046
+ res.writeHead(204);
1047
+ res.end();
1048
+ return;
1049
+ }
1050
+ const url = new URL(req.url, `http://localhost`);
1051
+ const agentRouteMatch = url.pathname.match(/^\/agent\/([a-z_-]+)$/);
1052
+ if (agentRouteMatch) {
1053
+ const agentName = agentRouteMatch[1];
1054
+ const client2 = [...connectedClients].find((c) => c.readyState === 1);
1055
+ if (!client2) {
1056
+ res.writeHead(503, { "Content-Type": "application/json" });
1057
+ res.end(JSON.stringify({ error: "Trace extension not connected." }));
1058
+ return;
1059
+ }
1060
+ try {
1061
+ let body = {};
1062
+ if (req.method === "POST") {
1063
+ body = await new Promise((resolve3, reject) => {
1064
+ let raw = "";
1065
+ req.on("data", (chunk) => raw += chunk.toString());
1066
+ req.on("end", () => {
1067
+ try {
1068
+ resolve3(JSON.parse(raw || "{}"));
1069
+ } catch {
1070
+ resolve3({});
1071
+ }
1072
+ });
1073
+ req.on("error", reject);
1074
+ });
538
1075
  }
539
- ws.on('close', () => {
540
- clientCount--;
541
- connectedClients.delete(ws);
542
- console.log(chalk.yellow('●') + ` Extension disconnected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
1076
+ const reqId = ++globalBrowserRequestId;
1077
+ client2.send(JSON.stringify({
1078
+ id: reqId,
1079
+ type: "AGENT_INVOKE",
1080
+ agent: agentName,
1081
+ query: body.query || ""
1082
+ }));
1083
+ const result = await new Promise((resolve3, reject) => {
1084
+ const timer = setTimeout(() => {
1085
+ globalBrowserPending.delete(reqId);
1086
+ reject(new Error("Agent timeout (60s). Extension debug agent did not respond."));
1087
+ }, 6e4);
1088
+ globalBrowserPending.set(reqId, { resolve: resolve3, reject, timer });
543
1089
  });
544
- ws.on('error', (err) => {
545
- console.error(chalk.red('WebSocket error:'), err.message);
546
- connectedClients.delete(ws);
1090
+ res.writeHead(200, { "Content-Type": "application/json" });
1091
+ res.end(JSON.stringify(result, null, 2));
1092
+ } catch (e) {
1093
+ res.writeHead(500, { "Content-Type": "application/json" });
1094
+ res.end(JSON.stringify({ error: e.message }));
1095
+ }
1096
+ return;
1097
+ }
1098
+ const browserQueryRoutes = {
1099
+ "/browser/console": "BROWSER_GET_CONSOLE",
1100
+ "/browser/network": "BROWSER_GET_NETWORK",
1101
+ "/browser/dom": "BROWSER_GET_DOM",
1102
+ "/browser/screenshot": "BROWSER_SCREENSHOT"
1103
+ };
1104
+ const isBrowserRoute = url.pathname in browserQueryRoutes || url.pathname === "/browser/eval";
1105
+ if (!isBrowserRoute) {
1106
+ res.writeHead(404, { "Content-Type": "application/json" });
1107
+ res.end(JSON.stringify({ error: "Not found. Available: /browser/{console,network,dom,eval,screenshot}" }));
1108
+ return;
1109
+ }
1110
+ const client = [...connectedClients].find(
1111
+ (c) => c.readyState === 1
1112
+ /* OPEN */
1113
+ );
1114
+ if (!client) {
1115
+ res.writeHead(503, { "Content-Type": "application/json" });
1116
+ res.end(JSON.stringify({ error: "Trace extension not connected. Open the extension and attach to a tab." }));
1117
+ return;
1118
+ }
1119
+ try {
1120
+ let body = {};
1121
+ if (req.method === "POST") {
1122
+ body = await new Promise((resolve3, reject) => {
1123
+ let raw = "";
1124
+ req.on("data", (chunk) => raw += chunk.toString());
1125
+ req.on("end", () => {
1126
+ try {
1127
+ resolve3(JSON.parse(raw || "{}"));
1128
+ } catch {
1129
+ resolve3({});
1130
+ }
1131
+ });
1132
+ req.on("error", reject);
547
1133
  });
1134
+ }
1135
+ const reqId = ++globalBrowserRequestId;
1136
+ const msgType = browserQueryRoutes[url.pathname] || "BROWSER_EVAL";
1137
+ const wsMsg = { id: reqId, type: msgType };
1138
+ if (url.pathname === "/browser/eval")
1139
+ wsMsg.code = body.code || "";
1140
+ client.send(JSON.stringify(wsMsg));
1141
+ const result = await new Promise((resolve3, reject) => {
1142
+ const timer = setTimeout(() => {
1143
+ globalBrowserPending.delete(reqId);
1144
+ reject(new Error("Browser query timeout (5s). Extension may not be attached to a tab."));
1145
+ }, 5e3);
1146
+ globalBrowserPending.set(reqId, { resolve: resolve3, reject, timer });
1147
+ });
1148
+ res.writeHead(200, { "Content-Type": "application/json" });
1149
+ res.end(JSON.stringify(result, null, 2));
1150
+ } catch (e) {
1151
+ res.writeHead(500, { "Content-Type": "application/json" });
1152
+ res.end(JSON.stringify({ error: e.message }));
1153
+ }
1154
+ });
1155
+ const BROWSER_HTTP_PORT = 8767;
1156
+ browserHttpServer.listen(BROWSER_HTTP_PORT, "127.0.0.1", () => {
1157
+ console.log(chalk.green("\u2713") + " Browser Query HTTP server on port " + chalk.cyan(BROWSER_HTTP_PORT));
1158
+ console.log(chalk.dim(" Coding agents can query: curl http://localhost:8767/browser/console"));
1159
+ });
1160
+ browserHttpServer.on("error", (e) => {
1161
+ if (e.code !== "EADDRINUSE")
1162
+ console.warn(chalk.yellow("\u26A0 Browser HTTP server error:"), e.message);
1163
+ });
1164
+ let agentServer = null;
1165
+ try {
1166
+ let agentPath = "@gettrace/agent/dist/node/index.js";
1167
+ if (process.env.TRACE_DEV_MODE) {
1168
+ agentPath = path4.resolve(__dirname, "../../packages/trace-agent/dist/node/index.js");
1169
+ console.log(chalk.magenta("\u2699") + " Dev Mode: Using local trace agent at " + chalk.dim(agentPath));
1170
+ }
1171
+ const { Server } = await import(agentPath);
1172
+ process.env.OPENCODE_EXPERIMENTAL = "1";
1173
+ process.env.OPENCODE_ENABLE_EXA = "1";
1174
+ process.env.OPENCODE_EXPERIMENTAL_LSP_TOOL = "1";
1175
+ process.env.OPENCODE_ENABLE_QUESTION_TOOL = "1";
1176
+ process.env.OPENCODE_CLIENT = "app";
1177
+ process.env.OPENCODE_DISABLE_AUTOUPDATE = "1";
1178
+ process.env.OPENCODE_DISABLE_TERMINAL_TITLE = "1";
1179
+ agentServer = await Server.listen({
1180
+ port: 8766,
1181
+ hostname: "127.0.0.1",
1182
+ cors: ["*"]
548
1183
  });
549
- wss.on('error', (error) => {
550
- if (error.code === 'EADDRINUSE') {
551
- console.error(chalk.red(`✗ Port ${port} is already in use. Try: trace dev --port 8766`));
552
- }
553
- else {
554
- console.error(chalk.red('Bridge error:'), error.message);
555
- }
556
- });
557
- // ── Graceful shutdown ─────────────────────────────────────
558
- const shutdown = () => {
559
- console.log();
560
- console.log(chalk.yellow('\nShutting down...'));
561
- if (devProcess && !devProcess.killed) {
562
- devProcess.kill('SIGTERM');
563
- }
564
- wss.close();
565
- process.exit(0);
566
- };
567
- process.on('SIGINT', shutdown);
568
- process.on('SIGTERM', shutdown);
1184
+ console.log(chalk.green("\u2713") + " Trace Agent Server listening on port " + chalk.cyan(8766));
1185
+ console.log(chalk.dim(" LSP \xB7 Exa search \xB7 Plan mode \xB7 All tools unlocked"));
1186
+ } catch (e) {
1187
+ console.error(chalk.yellow("\u26A0") + " Failed to start Trace Agent Server:", e.message);
1188
+ }
1189
+ const shutdown = async () => {
1190
+ console.log();
1191
+ console.log(chalk.yellow("\nShutting down..."));
1192
+ if (devProcess && !devProcess.killed) {
1193
+ devProcess.kill("SIGTERM");
1194
+ }
1195
+ if (agentServer) {
1196
+ await agentServer.stop();
1197
+ }
1198
+ wss.close();
1199
+ process.exit(0);
1200
+ };
1201
+ process.on("SIGINT", shutdown);
1202
+ process.on("SIGTERM", shutdown);
569
1203
  });
570
- // ============================================
571
- // SHARED MESSAGE HANDLER
572
- // Called by both `trace dev` and `trace connect`.
573
- // Attaches the full IDE bridge protocol to a WS.
574
- // ============================================
1204
+ var globalBrowserPending = /* @__PURE__ */ new Map();
1205
+ var globalBrowserRequestId = 0;
575
1206
  function attachMessageHandler(ws, projectPath) {
576
- // Per-connection undo stack
577
- const undoStack = [];
578
- // Build project info for GET_PROJECT_INFO responses
579
- let projectInfo = { projectPath };
580
- try {
581
- const pkgPath = path.join(projectPath, 'package.json');
582
- if (fs.existsSync(pkgPath)) {
583
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
584
- projectInfo = { ...projectInfo, name: pkg.name, version: pkg.version, description: pkg.description };
585
- }
1207
+ const undoStack = [];
1208
+ let projectInfo = { projectPath };
1209
+ try {
1210
+ const pkgPath = path4.join(projectPath, "package.json");
1211
+ if (fs4.existsSync(pkgPath)) {
1212
+ const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf-8"));
1213
+ projectInfo = { ...projectInfo, name: pkg.name, version: pkg.version, description: pkg.description };
586
1214
  }
587
- catch (_) { }
588
- ws.on('message', async (data) => {
589
- try {
590
- const message = JSON.parse(data.toString());
591
- const { id, type } = message;
592
- let response = { id };
593
- switch (type) {
594
- case 'GET_PROJECT_INFO':
595
- response.data = projectInfo;
596
- break;
597
- case 'READ_FILE':
598
- try {
599
- const filePath = path.resolve(projectPath, message.filePath);
600
- if (!filePath.startsWith(projectPath)) {
601
- response.error = 'Access denied';
602
- }
603
- else if (fs.existsSync(filePath)) {
604
- const stat = fs.statSync(filePath);
605
- // Binary file detection (check first 4KB for null bytes)
606
- if (stat.size > 0) {
607
- const fd = fs.openSync(filePath, 'r');
608
- const sample = Buffer.alloc(Math.min(4096, stat.size));
609
- fs.readSync(fd, sample, 0, sample.length, 0);
610
- fs.closeSync(fd);
611
- if (sample.includes(0)) {
612
- response.error = `Cannot read binary file: ${message.filePath}`;
613
- break;
614
- }
615
- }
616
- const rawContent = fs.readFileSync(filePath, 'utf-8');
617
- const allLines = rawContent.split('\n');
618
- const offset = message.offset || 1;
619
- const limit = message.limit || 2000;
620
- const startIdx = Math.max(0, offset - 1);
621
- const endIdx = Math.min(allLines.length, startIdx + limit);
622
- const sliced = allLines.slice(startIdx, endIdx);
623
- // Line-number prefixed output (like OpenCode)
624
- const numbered = sliced.map((line, i) => `${startIdx + i + 1}: ${line}`).join('\n');
625
- const truncated = endIdx < allLines.length;
626
- // ── READ TOKEN ───────────────────────────────────────────────
627
- // Agents pass this back in EDIT_FILE / REPLACE_LINES to prove
628
- // the file hasn't changed since they last read it. If the token
629
- // mismatches on a subsequent write, the write is rejected so
630
- // stale line numbers cannot corrupt the file.
631
- const _rtStat = fs.statSync(filePath);
632
- const _readToken = `${Math.round(_rtStat.mtimeMs)}-${_rtStat.size}`;
633
- // ─────────────────────────────────────────────────────────────
634
- response.data = {
635
- content: numbered,
636
- // rawContent intentionally omitted — agents use line-numbered
637
- // content for edits; sending raw doubles context size per read.
638
- exists: true,
639
- path: message.filePath,
640
- totalLines: allLines.length,
641
- showing: { from: offset, to: endIdx, truncated },
642
- readToken: _readToken,
643
- };
644
- }
645
- else {
646
- // Suggest similar files if not found
647
- const dir = path.dirname(filePath);
648
- const base = path.basename(filePath);
649
- let suggestions = [];
650
- try {
651
- suggestions = fs.readdirSync(dir)
652
- .filter(e => e.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(e.toLowerCase()))
653
- .slice(0, 3)
654
- .map(e => path.relative(projectPath, path.join(dir, e)));
655
- }
656
- catch (_) { }
657
- response.data = { exists: false, suggestions };
658
- }
659
- }
660
- catch (e) {
661
- response.error = e.message;
662
- }
663
- break;
664
- case 'GET_SOURCE':
665
- try {
666
- const filePath = path.resolve(projectPath, message.filePath);
667
- if (!filePath.startsWith(projectPath)) {
668
- response.error = 'Access denied';
669
- }
670
- else if (fs.existsSync(filePath)) {
671
- const content = fs.readFileSync(filePath, 'utf-8');
672
- const lines = content.split('\n');
673
- const start = Math.max(0, (message.lineStart || 1) - 1);
674
- const end = message.lineEnd ? Math.min(lines.length, message.lineEnd) : lines.length;
675
- response.data = {
676
- lines: lines.slice(start, end),
677
- startLine: start + 1,
678
- endLine: end,
679
- totalLines: lines.length
680
- };
681
- }
682
- else {
683
- response.error = 'File not found';
684
- }
685
- }
686
- catch (e) {
687
- response.error = e.message;
688
- }
689
- break;
690
- case 'GET_ERROR_CONTEXT':
691
- try {
692
- const filePath = path.resolve(projectPath, message.filePath);
693
- if (!filePath.startsWith(projectPath)) {
694
- response.error = 'Access denied';
695
- }
696
- else if (fs.existsSync(filePath)) {
697
- const content = fs.readFileSync(filePath, 'utf-8');
698
- const lines = content.split('\n');
699
- const targetLine = message.line || 1;
700
- const contextLines = message.contextLines || 5;
701
- const start = Math.max(0, targetLine - contextLines - 1);
702
- const end = Math.min(lines.length, targetLine + contextLines);
703
- response.data = {
704
- lines: lines.slice(start, end).map((line, i) => ({
705
- number: start + i + 1,
706
- content: line,
707
- isError: start + i + 1 === targetLine
708
- })),
709
- errorLine: targetLine,
710
- filePath: message.filePath
711
- };
712
- }
713
- else {
714
- response.error = 'File not found';
715
- }
716
- }
717
- catch (e) {
718
- response.error = e.message;
719
- }
720
- break;
721
- case 'GET_FILE_TREE':
722
- try {
723
- const depth = message.depth || 3;
724
- const tree = getFileTree(projectPath, depth);
725
- response.data = { tree };
726
- }
727
- catch (e) {
728
- response.error = e.message;
729
- }
730
- break;
731
- case 'SEARCH_CODE':
732
- try {
733
- const { matches, engine } = await searchCode(projectPath, message.query, {
734
- isRegex: message.isRegex || false,
735
- caseSensitive: message.caseSensitive !== false,
736
- maxResults: message.maxResults || 20,
737
- });
738
- response.data = { matches, engine };
739
- }
740
- catch (e) {
741
- response.error = e.message;
742
- }
743
- break;
744
- case 'FIND_FILES':
745
- try {
746
- const pattern = message.pattern || '';
747
- // Extract the filename from a glob like **/Services.css
748
- const fileName = pattern.split('/').pop().replace(/\*\*/g, '').replace(/\*/g, '');
749
- const maxResults = message.maxResults || 20;
750
- const ignorePatterns = ['node_modules', '.git', 'dist', 'build', '.next', '.cache'];
751
- const found = [];
752
- function findFiles(dir) {
753
- if (found.length >= maxResults)
754
- return;
755
- try {
756
- const items = fs.readdirSync(dir);
757
- for (const item of items) {
758
- if (found.length >= maxResults)
759
- break;
760
- if (ignorePatterns.includes(item) || item.startsWith('.'))
761
- continue;
762
- const fullPath = path.join(dir, item);
763
- try {
764
- const stat = fs.statSync(fullPath);
765
- if (stat.isDirectory()) {
766
- findFiles(fullPath);
767
- }
768
- else {
769
- // Match by filename (with optional wildcard)
770
- const matchByName = !fileName || item === fileName ||
771
- (pattern.includes('*') && item.endsWith(fileName));
772
- if (matchByName) {
773
- found.push(path.relative(projectPath, fullPath));
774
- }
775
- }
776
- }
777
- catch (e) { /* skip */ }
778
- }
779
- }
780
- catch (e) { /* skip */ }
781
- }
782
- findFiles(projectPath);
783
- response.data = found;
784
- }
785
- catch (e) {
786
- response.error = e.message;
787
- }
788
- // ========== PROJECT DETECTION (Code Export Pipeline) ==========
789
- case 'DETECT_PROJECT':
790
- try {
791
- const pkgPath = path.join(projectPath, 'package.json');
792
- let deps = {};
793
- let devDeps = {};
794
- let pkgName = '';
795
- if (fs.existsSync(pkgPath)) {
796
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
797
- deps = pkg.dependencies || {};
798
- devDeps = pkg.devDependencies || {};
799
- pkgName = pkg.name || '';
800
- }
801
- const allDeps = { ...deps, ...devDeps };
802
- // --- Framework detection ---
803
- let framework = 'html';
804
- if (allDeps['next'])
805
- framework = 'next.js';
806
- else if (allDeps['nuxt'] || allDeps['nuxt3'])
807
- framework = 'nuxt';
808
- else if (allDeps['gatsby'])
809
- framework = 'gatsby';
810
- else if (allDeps['@remix-run/react'])
811
- framework = 'remix';
812
- else if (allDeps['svelte'] || allDeps['@sveltejs/kit'])
813
- framework = 'svelte';
814
- else if (allDeps['vue'])
815
- framework = 'vue';
816
- else if (allDeps['@angular/core'])
817
- framework = 'angular';
818
- else if (allDeps['react'])
819
- framework = 'react';
820
- // --- TypeScript detection ---
821
- const typescript = !!allDeps['typescript'] || fs.existsSync(path.join(projectPath, 'tsconfig.json'));
822
- const ext = typescript ? '.tsx' : '.jsx';
823
- // --- Styling detection ---
824
- let styling = 'plain-css';
825
- if (allDeps['tailwindcss'])
826
- styling = 'tailwind';
827
- else if (allDeps['styled-components'])
828
- styling = 'styled-components';
829
- else if (allDeps['@emotion/react'] || allDeps['@emotion/styled'])
830
- styling = 'emotion';
831
- else if (allDeps['sass'] || allDeps['node-sass'])
832
- styling = 'sass';
833
- // CSS Modules: detected by file presence later
834
- // --- Router detection (Next.js specific) ---
835
- let router = null;
836
- if (framework === 'next.js') {
837
- if (fs.existsSync(path.join(projectPath, 'src/app')) || fs.existsSync(path.join(projectPath, 'app'))) {
838
- router = 'app';
839
- }
840
- else if (fs.existsSync(path.join(projectPath, 'src/pages')) || fs.existsSync(path.join(projectPath, 'pages'))) {
841
- router = 'pages';
842
- }
843
- }
844
- // --- Key file location finder ---
845
- const findFirst = (candidates) => {
846
- for (const c of candidates) {
847
- if (fs.existsSync(path.join(projectPath, c)))
848
- return c;
849
- }
850
- return null;
851
- };
852
- const layoutFile = findFirst([
853
- 'src/app/layout.tsx', 'src/app/layout.jsx', 'src/app/layout.js',
854
- 'app/layout.tsx', 'app/layout.jsx', 'app/layout.js',
855
- 'src/pages/_app.tsx', 'src/pages/_app.jsx', 'pages/_app.tsx', 'pages/_app.jsx',
856
- 'src/App.tsx', 'src/App.jsx', 'src/App.js',
857
- 'src/main.tsx', 'src/main.jsx', 'src/main.js',
858
- 'index.html'
859
- ]);
860
- const globalStyleFile = findFirst([
861
- 'src/app/globals.css', 'src/app/global.css',
862
- 'app/globals.css', 'app/global.css',
863
- 'src/index.css', 'src/styles/globals.css', 'src/styles/global.css',
864
- 'src/App.css', 'src/styles.css',
865
- 'styles/globals.css', 'styles/global.css',
866
- 'css/style.css', 'css/main.css',
867
- 'style.css', 'styles.css'
868
- ]);
869
- // Initial guess via disk — overridden after tree scan with real component count
870
- let componentsDir = findFirst([
871
- 'src/components', 'src/app/components', 'components',
872
- 'src/ui', 'src/app/ui'
873
- ]);
874
- // --- Tailwind config ---
875
- let tailwindConfig = null;
876
- let tailwindVersion = null;
877
- if (styling === 'tailwind') {
878
- tailwindConfig = findFirst([
879
- 'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'
880
- ]);
881
- tailwindVersion = allDeps['tailwindcss'] || null;
882
- }
883
- // --- Detect code conventions from existing components ---
884
- let componentPattern = 'arrow-function';
885
- let exportPattern = 'default';
886
- let sampleComponentFile = null;
887
- let sampleComponentCode = null;
888
- if (componentsDir) {
889
- const compDir = path.join(projectPath, componentsDir);
890
- try {
891
- const files = fs.readdirSync(compDir).filter(f => f.endsWith('.tsx') || f.endsWith('.jsx') || f.endsWith('.js') || f.endsWith('.vue') || f.endsWith('.svelte'));
892
- if (files.length > 0) {
893
- sampleComponentFile = path.join(componentsDir, files[0]);
894
- const code = fs.readFileSync(path.join(compDir, files[0]), 'utf-8');
895
- // Only include first 80 lines as a pattern reference
896
- sampleComponentCode = code.split('\n').slice(0, 80).join('\n');
897
- // Detect patterns
898
- if (/^export default function /m.test(code)) {
899
- componentPattern = 'function-declaration';
900
- exportPattern = 'default';
901
- }
902
- else if (/^export function /m.test(code)) {
903
- componentPattern = 'function-declaration';
904
- exportPattern = 'named';
905
- }
906
- else if (/const \w+ = \(/.test(code) || /const \w+ = \(\) =>/.test(code)) {
907
- componentPattern = 'arrow-function';
908
- }
909
- if (/^export default /m.test(code))
910
- exportPattern = 'default';
911
- else if (/^export \{/m.test(code) || /^export const/m.test(code))
912
- exportPattern = 'named';
913
- // CSS Modules detection from component imports
914
- if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(code)) {
915
- styling = 'css-modules';
916
- }
917
- }
918
- }
919
- catch (e) { /* skip */ }
920
- }
921
- // --- Also check layout file for CSS Modules if not yet detected ---
922
- if (styling === 'plain-css' && layoutFile) {
923
- try {
924
- const layoutCode = fs.readFileSync(path.join(projectPath, layoutFile), 'utf-8');
925
- if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(layoutCode)) {
926
- styling = 'css-modules';
927
- }
928
- }
929
- catch (e) { /* skip */ }
930
- }
931
- // --- Read a snippet of global CSS for context ---
932
- let globalStylePreview = null;
933
- if (globalStyleFile) {
934
- try {
935
- const css = fs.readFileSync(path.join(projectPath, globalStyleFile), 'utf-8');
936
- globalStylePreview = css.split('\n').slice(0, 40).join('\n');
937
- }
938
- catch (e) { /* skip */ }
939
- }
940
- // --- Build filtered scaffolding tree (UI-relevant files only) ---
941
- const SKIP_DIRS = new Set([
942
- 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit',
943
- 'dist', 'build', '.cache', '.turbo', 'coverage',
944
- '__pycache__', '.vercel', '.output', '.parcel-cache'
945
- ]);
946
- const UI_EXTENSIONS = new Set([
947
- '.tsx', '.jsx', '.js', '.ts', '.vue', '.svelte',
948
- '.css', '.scss', '.sass', '.less', '.module.css', '.module.scss',
949
- '.html', '.astro'
950
- ]);
951
- const treeLines = [];
952
- const MAX_TREE_LINES = 120;
953
- // Track dir → count of Pascal-case component files for smart detection
954
- const dirComponentCount = new Map();
955
- const buildTree = (dir, prefix, depth) => {
956
- if (depth > 4 || treeLines.length >= MAX_TREE_LINES)
957
- return;
958
- try {
959
- const entries = fs.readdirSync(path.join(projectPath, dir), { withFileTypes: true });
960
- // Sort: directories first, then files
961
- const sorted = entries
962
- .filter(e => !e.name.startsWith('.') || e.name === '.env')
963
- .sort((a, b) => {
964
- if (a.isDirectory() && !b.isDirectory())
965
- return -1;
966
- if (!a.isDirectory() && b.isDirectory())
967
- return 1;
968
- return a.name.localeCompare(b.name);
969
- });
970
- for (let i = 0; i < sorted.length && treeLines.length < MAX_TREE_LINES; i++) {
971
- const entry = sorted[i];
972
- const isLast = i === sorted.length - 1;
973
- const connector = isLast ? '└── ' : '├── ';
974
- const childPrefix = isLast ? ' ' : '│ ';
975
- const childPath = dir ? `${dir}/${entry.name}` : entry.name;
976
- if (entry.isDirectory()) {
977
- if (SKIP_DIRS.has(entry.name))
978
- continue;
979
- treeLines.push(`${prefix}${connector}${entry.name}/`);
980
- buildTree(childPath, prefix + childPrefix, depth + 1);
981
- }
982
- else {
983
- const ext = path.extname(entry.name).toLowerCase();
984
- // Count Pascal-case component files per dir (Footer.tsx, Button.tsx, etc.)
985
- const COMPONENT_EXTS = new Set(['.tsx', '.jsx', '.vue', '.svelte']);
986
- if (COMPONENT_EXTS.has(ext) && /^[A-Z]/.test(entry.name)) {
987
- dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
988
- }
989
- // Include UI-relevant files + config files at root
990
- if (UI_EXTENSIONS.has(ext) || (depth <= 1 && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
991
- entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
992
- entry.name.startsWith('vite.config')))) {
993
- treeLines.push(`${prefix}${connector}${entry.name}`);
994
- }
995
- }
996
- }
997
- }
998
- catch (e) { /* permission error, skip */ }
999
- };
1000
- // Only scan UI-relevant root dirs (not the entire project)
1001
- const scanRoots = ['src', 'app', 'pages', 'components', 'styles', 'public', 'lib', 'utils'];
1002
- const existingRoots = [];
1003
- for (const root of scanRoots) {
1004
- if (fs.existsSync(path.join(projectPath, root))) {
1005
- existingRoots.push(root);
1006
- }
1007
- }
1008
- // Also add root-level config files
1009
- try {
1010
- const rootEntries = fs.readdirSync(projectPath, { withFileTypes: true });
1011
- for (const entry of rootEntries) {
1012
- if (!entry.isDirectory() && (entry.name === 'package.json' || entry.name === 'tsconfig.json' ||
1013
- entry.name.startsWith('tailwind.config') || entry.name.startsWith('next.config') ||
1014
- entry.name.startsWith('vite.config') || entry.name === 'index.html')) {
1015
- treeLines.push(entry.name);
1016
- }
1017
- }
1018
- }
1019
- catch (e) { /* skip */ }
1020
- // Build tree for each UI root
1021
- for (const root of existingRoots) {
1022
- treeLines.push(`${root}/`);
1023
- buildTree(root, '', 1);
1024
- }
1025
- const projectTree = treeLines.length > 0 ? treeLines.join('\n') : null;
1026
- // --- Override componentsDir with real scan result ---
1027
- // Pick the dir with the most Pascal-case component files.
1028
- // This beats any hardcoded list: works for src/features, src/modules,
1029
- // src/views, src/app/components — whatever the project uses.
1030
- if (dirComponentCount.size > 0) {
1031
- let bestDir = '';
1032
- let bestCount = 0;
1033
- for (const [dir, count] of dirComponentCount.entries()) {
1034
- if (count > bestCount) {
1035
- bestCount = count;
1036
- bestDir = dir;
1037
- }
1038
- }
1039
- if (bestCount > 0)
1040
- componentsDir = bestDir;
1041
- }
1042
- response.data = {
1043
- framework,
1044
- typescript,
1045
- styling,
1046
- router,
1047
- ext,
1048
- pkgName,
1049
- // Key file locations
1050
- layoutFile,
1051
- globalStyleFile,
1052
- componentsDir,
1053
- tailwindConfig,
1054
- tailwindVersion,
1055
- // Code conventions
1056
- componentPattern,
1057
- exportPattern,
1058
- sampleComponentFile,
1059
- sampleComponentCode,
1060
- // CSS context
1061
- globalStylePreview,
1062
- // Scaffolding tree
1063
- projectTree,
1064
- };
1065
- console.log(chalk.blue('ℹ') + ` Project detected: ${chalk.yellow(framework)} + ${chalk.cyan(styling)}${typescript ? chalk.dim(' (TS)') : ''}${router ? chalk.dim(` [${router} router]`) : ''}`);
1066
- }
1067
- catch (e) {
1068
- response.error = e.message;
1069
- }
1070
- break;
1071
- case 'BUILD_CLASS_INDEX':
1072
- // ────────────────────────────────────────────────────────────────
1073
- // Scans all component files (.tsx, .jsx, .vue, .svelte) for
1074
- // className attributes and builds a lookup:
1075
- // { classSignature → { file: string, line: number } }
1076
- //
1077
- // Used by FileResolver in the extension to find which source file
1078
- // owns a given DOM element when React Fiber _debugSource is null.
1079
- // ────────────────────────────────────────────────────────────────
1080
- try {
1081
- const COMPONENT_EXTS_IDX = new Set(['.tsx', '.jsx', '.vue', '.svelte', '.js', '.ts']);
1082
- const SKIP_DIRS_IDX = new Set(['node_modules', '.git', '.next', '.nuxt', 'dist', 'build', '.cache']);
1083
- const index = {};
1084
- let filesScanned = 0;
1085
- let classesIndexed = 0;
1086
- const scanDir = (dir) => {
1087
- try {
1088
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1089
- for (const entry of entries) {
1090
- if (entry.name.startsWith('.'))
1091
- continue;
1092
- const fullPath = path.join(dir, entry.name);
1093
- const relPath = path.relative(projectPath, fullPath).replace(/\\/g, '/');
1094
- if (entry.isDirectory()) {
1095
- if (SKIP_DIRS_IDX.has(entry.name))
1096
- continue;
1097
- scanDir(fullPath);
1098
- }
1099
- else {
1100
- const ext = path.extname(entry.name).toLowerCase();
1101
- if (!COMPONENT_EXTS_IDX.has(ext))
1102
- continue;
1103
- try {
1104
- const content = fs.readFileSync(fullPath, 'utf-8');
1105
- const lines = content.split('\n');
1106
- filesScanned++;
1107
- for (let i = 0; i < lines.length; i++) {
1108
- const line = lines[i];
1109
- // Match className="..." and className={'...'} and class="..."
1110
- const classMatches = line.matchAll(/(?:className|class)\s*=\s*(?:"([^"]+)"|'([^']+)'|\{(?:`([^`]+)`|"([^"]+)"|'([^']+)')\})/g);
1111
- for (const match of classMatches) {
1112
- const classValue = match[1] || match[2] || match[3] || match[4] || match[5];
1113
- if (!classValue)
1114
- continue;
1115
- const classes = classValue
1116
- .split(/\s+/)
1117
- .filter((c) => c.length > 2 &&
1118
- !c.startsWith('$') &&
1119
- !c.includes('{') &&
1120
- !c.includes('}'));
1121
- if (classes.length === 0)
1122
- continue;
1123
- // Full 2-3 class signature as key
1124
- const sig = classes.slice(0, 3).sort().join(' ');
1125
- if (sig && !index[sig]) {
1126
- index[sig] = { file: relPath, line: i + 1 };
1127
- classesIndexed++;
1128
- }
1129
- // Individual distinctive classes
1130
- for (const cls of classes) {
1131
- // Skip very common Tailwind utilities that match too many elements
1132
- if (/^(flex|grid|block|hidden|relative|absolute|fixed|sticky|inline|w-|h-|p-|m-|text-|bg-|border|rounded|flex-|grid-|col-|row-)/.test(cls))
1133
- continue;
1134
- if (cls.length > 5 && !index[cls]) {
1135
- index[cls] = { file: relPath, line: i + 1 };
1136
- classesIndexed++;
1137
- }
1138
- }
1139
- }
1140
- }
1141
- }
1142
- catch (e) { /* skip unreadable files */ }
1143
- }
1144
- }
1145
- }
1146
- catch (e) { /* skip unreadable dirs */ }
1147
- };
1148
- scanDir(projectPath);
1149
- response.data = {
1150
- index,
1151
- filesScanned,
1152
- classesIndexed,
1153
- };
1154
- console.log(chalk.blue('ℹ') + ` Class index built: ${chalk.yellow(classesIndexed)} classes across ${chalk.cyan(filesScanned)} files`);
1155
- }
1156
- catch (e) {
1157
- response.error = e.message;
1158
- }
1159
- break;
1160
- // ========== FILE WRITING (UI Design Export) ==========
1161
- // --- Undo Stack ---
1162
- // Snapshots file contents before every write/append/edit.
1163
- // UNDO_LAST pops the most recent snapshot and restores it.
1164
- // Max 20 entries to avoid unbounded memory growth.
1165
- case 'WRITE_FILE':
1166
- try {
1167
- const filePath = path.resolve(projectPath, message.filePath);
1168
- if (!filePath.startsWith(projectPath)) {
1169
- response.error = 'Access denied: Path outside project';
1170
- }
1171
- else {
1172
- // Snapshot before write
1173
- const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
1174
- undoStack.push({ filePath, prevContent, operation: 'WRITE_FILE', timestamp: Date.now(), relativePath: message.filePath });
1175
- if (undoStack.length > 20)
1176
- undoStack.shift();
1177
- // --- CSS safety: validate balanced braces ---
1178
- if (filePath.endsWith('.css')) {
1179
- const writeContent = message.content || '';
1180
- const opens = (writeContent.match(/\{/g) || []).length;
1181
- const closes = (writeContent.match(/\}/g) || []).length;
1182
- if (opens !== closes) {
1183
- // Remove snapshot since we're rejecting the write
1184
- undoStack.pop();
1185
- response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before writing.`;
1186
- break;
1187
- }
1188
- }
1189
- const dirPath = path.dirname(filePath);
1190
- if (!fs.existsSync(dirPath)) {
1191
- fs.mkdirSync(dirPath, { recursive: true });
1192
- }
1193
- await withFileLock(filePath, async () => {
1194
- fs.writeFileSync(filePath, message.content, 'utf-8');
1195
- // Auto-format after write
1196
- const formatter = await autoFormat(filePath, projectPath);
1197
- response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
1198
- console.log(chalk.blue('ℹ') + ` Wrote file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
1199
- });
1200
- // ── LSP: Post-write TypeScript diagnostics ────────────────────
1201
- // Runs tsc --noEmit (6s timeout). Never throws. Returns [] for
1202
- // non-TS files or projects without tsconfig.json.
1203
- const _wLsp = await checkDiagnostics(filePath, projectPath);
1204
- if (_wLsp.ran && response.data) {
1205
- response.data.lspDiagnostics = _wLsp.diagnostics;
1206
- if (_wLsp.summary) {
1207
- response.data.lspSummary = _wLsp.summary;
1208
- if (_wLsp.diagnostics.some(d => d.severity === 'error')) {
1209
- console.log(chalk.yellow('⚠') + ` ${_wLsp.summary}`);
1210
- }
1211
- }
1212
- }
1213
- // ─────────────────────────────────────────────────────────────
1214
- }
1215
- }
1216
- catch (e) {
1217
- response.error = e.message;
1218
- }
1219
- break;
1220
- case 'APPEND_FILE':
1221
- try {
1222
- const filePath = path.resolve(projectPath, message.filePath);
1223
- if (!filePath.startsWith(projectPath)) {
1224
- response.error = 'Access denied: Path outside project';
1225
- }
1226
- else {
1227
- const isCssFile = filePath.endsWith('.css');
1228
- const newContent = message.content || '';
1229
- // --- CSS safety: validate balanced braces ---
1230
- if (isCssFile) {
1231
- const opens = (newContent.match(/\{/g) || []).length;
1232
- const closes = (newContent.match(/\}/g) || []).length;
1233
- if (opens !== closes) {
1234
- response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before appending.`;
1235
- break;
1236
- }
1237
- }
1238
- // Snapshot before append
1239
- const prevContent = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : null;
1240
- undoStack.push({ filePath, prevContent, operation: 'APPEND_FILE', timestamp: Date.now(), relativePath: message.filePath });
1241
- if (undoStack.length > 20)
1242
- undoStack.shift();
1243
- // --- CSS safety: hoist @import/@charset to file top ---
1244
- if (isCssFile && prevContent !== null) {
1245
- // Extract @import and @charset from the NEW content
1246
- const importRegex = /^@(?:import|charset)\s+[^;]+;\s*$/gm;
1247
- const newImports = [];
1248
- const bodyContent = newContent.replace(importRegex, (match) => {
1249
- newImports.push(match.trim());
1250
- return '';
1251
- }).trim();
1252
- if (newImports.length > 0) {
1253
- // Find where existing imports end in the original file
1254
- const existingLines = prevContent.split('\n');
1255
- let lastImportLineIdx = -1;
1256
- for (let i = 0; i < existingLines.length; i++) {
1257
- const trimmed = existingLines[i].trim();
1258
- if (trimmed.startsWith('@import') || trimmed.startsWith('@charset')) {
1259
- lastImportLineIdx = i;
1260
- }
1261
- // Stop scanning after first non-import, non-comment, non-empty line
1262
- if (trimmed && !trimmed.startsWith('@import') && !trimmed.startsWith('@charset') && !trimmed.startsWith('/*') && !trimmed.startsWith('*') && !trimmed.startsWith('//')) {
1263
- break;
1264
- }
1265
- }
1266
- // Deduplicate: only add imports that don't already exist
1267
- const dedupedImports = newImports.filter(imp => !prevContent.includes(imp));
1268
- // Build new file: existing imports + new imports + rest of existing + new body
1269
- const insertIdx = lastImportLineIdx + 1;
1270
- const topLines = existingLines.slice(0, insertIdx);
1271
- const restLines = existingLines.slice(insertIdx);
1272
- const merged = [
1273
- ...topLines,
1274
- ...dedupedImports,
1275
- ...restLines,
1276
- '', // separator
1277
- bodyContent
1278
- ].join('\n');
1279
- fs.writeFileSync(filePath, merged, 'utf-8');
1280
- console.log(chalk.blue('ℹ') + ` CSS-safe append: hoisted ${dedupedImports.length} @import(s) to top of ${message.filePath}`);
1281
- }
1282
- else {
1283
- // No imports in new content — simple append
1284
- fs.appendFileSync(filePath, '\n\n' + newContent, 'utf-8');
1285
- }
1286
- }
1287
- else {
1288
- // Non-CSS file or new file — simple append
1289
- fs.appendFileSync(filePath, '\n' + newContent, 'utf-8');
1290
- }
1291
- // Auto-format after append
1292
- const formatter = await autoFormat(filePath, projectPath);
1293
- response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
1294
- console.log(chalk.blue('ℹ') + ` Appended file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : '') + chalk.dim(' [undo saved]'));
1295
- }
1296
- }
1297
- catch (e) {
1298
- response.error = e.message;
1299
- }
1300
- break;
1301
- // ========== AST-BASED EDITING ==========
1302
- case 'EDIT_CLASSNAME': {
1303
- // ────────────────────────────────────────────────────────
1304
- // AST-precise className replacement for JSX/TSX files.
1305
- // Survives Prettier reformatting and finds the right element
1306
- // even when fuzzy string matching would pick the wrong line.
1307
- //
1308
- // Required: filePath, newValue
1309
- // Optional: oldValue (current className to replace)
1310
- // lineHint (from resolvedLine — strongly preferred)
1311
- // readToken (drift guard, same as EDIT_FILE)
1312
- // ────────────────────────────────────────────────────────
1313
- try {
1314
- const filePath = path.resolve(projectPath, message.filePath);
1315
- if (!filePath.startsWith(projectPath)) {
1316
- response.error = 'Access denied: Path outside project';
1317
- break;
1318
- }
1319
- if (!fs.existsSync(filePath)) {
1320
- response.error = 'File not found: ' + message.filePath;
1321
- break;
1322
- }
1323
- // Read-token drift guard (same as EDIT_FILE)
1324
- if (message.readToken) {
1325
- const stat = fs.statSync(filePath);
1326
- const token = `${Math.round(stat.mtimeMs)}-${stat.size}`;
1327
- if (token !== message.readToken) {
1328
- response.error =
1329
- 'File has changed since last read (readToken mismatch). ' +
1330
- 'Re-read with read_project_file before editing.';
1331
- break;
1332
- }
1333
- }
1334
- const source = fs.readFileSync(filePath, 'utf-8');
1335
- // Snapshot for undo
1336
- undoStack.push({ filePath, prevContent: source, operation: 'EDIT_CLASSNAME', timestamp: Date.now(), relativePath: message.filePath });
1337
- if (undoStack.length > 20)
1338
- undoStack.shift();
1339
- const result = editJSXClassName(source, {
1340
- oldValue: message.oldValue,
1341
- newValue: message.newValue,
1342
- lineHint: message.lineHint,
1343
- });
1344
- if (!result.success) {
1345
- undoStack.pop();
1346
- response.error = result.error ?? 'AST edit failed';
1347
- break;
1348
- }
1349
- await withFileLock(filePath, async () => {
1350
- fs.writeFileSync(filePath, result.code, 'utf-8');
1351
- const formatter = await autoFormat(filePath, projectPath);
1352
- response.data = {
1353
- success: true,
1354
- path: filePath,
1355
- strategy: result.strategy,
1356
- matchedLine: result.matchedLine,
1357
- formatted: formatter,
1358
- undoAvailable: true,
1359
- };
1360
- console.log(chalk.blue('ℹ') +
1361
- ` EDIT_CLASSNAME: ${message.filePath}` +
1362
- chalk.dim(` [${result.strategy} @ line ${result.matchedLine}]`) +
1363
- (formatter ? chalk.dim(` (${formatter})`) : '') +
1364
- chalk.dim(' [undo saved]'));
1365
- });
1366
- // LSP type-check after write
1367
- const lsp = await checkDiagnostics(filePath, projectPath);
1368
- if (lsp.ran && response.data) {
1369
- response.data.lspDiagnostics = lsp.diagnostics;
1370
- if (lsp.summary)
1371
- response.data.lspSummary = lsp.summary;
1372
- }
1373
- }
1374
- catch (e) {
1375
- response.error = e.message;
1376
- }
1377
- break;
1215
+ } catch (_) {
1216
+ }
1217
+ ws.on("message", async (data) => {
1218
+ try {
1219
+ const message = JSON.parse(data.toString());
1220
+ const { id, type } = message;
1221
+ let response = { id };
1222
+ switch (type) {
1223
+ case "GET_PROJECT_INFO":
1224
+ response.data = projectInfo;
1225
+ break;
1226
+ case "READ_FILE":
1227
+ try {
1228
+ const filePath = path4.resolve(projectPath, message.filePath);
1229
+ if (!filePath.startsWith(projectPath)) {
1230
+ response.error = "Access denied";
1231
+ } else if (fs4.existsSync(filePath)) {
1232
+ const stat = fs4.statSync(filePath);
1233
+ if (stat.size > 0) {
1234
+ const fd = fs4.openSync(filePath, "r");
1235
+ const sample = Buffer.alloc(Math.min(4096, stat.size));
1236
+ fs4.readSync(fd, sample, 0, sample.length, 0);
1237
+ fs4.closeSync(fd);
1238
+ if (sample.includes(0)) {
1239
+ response.error = `Cannot read binary file: ${message.filePath}`;
1240
+ break;
1378
1241
  }
1379
- case 'EDIT_FILE':
1380
- try {
1381
- const filePath = path.resolve(projectPath, message.filePath);
1382
- if (!filePath.startsWith(projectPath)) {
1383
- response.error = 'Access denied: Path outside project';
1384
- }
1385
- else if (!fs.existsSync(filePath)) {
1386
- // Find similarly-named files to help the LLM self-correct
1387
- const requestedBase = path.basename(message.filePath);
1388
- const requestedExt = path.extname(requestedBase);
1389
- const requestedStem = requestedBase.replace(requestedExt, '');
1390
- // Walk shallow project tree to find candidates
1391
- const suggestions = [];
1392
- const dirsToSearch = ['src/app', 'app', 'src/pages', 'pages', 'src/components', 'components', 'src'];
1393
- for (const dir of dirsToSearch) {
1394
- const absDir = path.join(projectPath, dir);
1395
- if (fs.existsSync(absDir)) {
1396
- try {
1397
- const files = fs.readdirSync(absDir, { recursive: true });
1398
- for (const f of files.slice(0, 100)) {
1399
- const fBase = path.basename(f);
1400
- // Same stem, any extension (page.tsx → page.js, page.jsx)
1401
- if (fBase.startsWith(requestedStem + '.') ||
1402
- fBase === requestedBase) {
1403
- suggestions.push(path.join(dir, f).replace(/\\/g, '/'));
1404
- }
1405
- }
1406
- }
1407
- catch (e) { /* skip unreadable dirs */ }
1408
- }
1409
- }
1410
- const hint = suggestions.length > 0
1411
- ? ' Did you mean: ' + suggestions.slice(0, 3).join(' or ') + '?'
1412
- : ' Double-check the path against detect_project() output (layoutFile, globalStyleFile) or projectTree.';
1413
- response.error = 'File not found: "' + message.filePath + '".' + hint;
1414
- }
1415
- else {
1416
- // ── READ TOKEN DRIFT GUARD ───────────────────────────────────
1417
- // If the caller passed readToken from their last READ_FILE,
1418
- // validate the file hasn't changed (mtime + size). Stale
1419
- // content from a prior read is the #1 cause of silent data
1420
- // corruption on multi-step agent edits.
1421
- if (message.readToken) {
1422
- const _eStat = fs.statSync(filePath);
1423
- const _eToken = `${Math.round(_eStat.mtimeMs)}-${_eStat.size}`;
1424
- if (_eToken !== message.readToken) {
1425
- response.error =
1426
- 'File has changed since last read (readToken mismatch). ' +
1427
- 'Re-read the file with read_project_file to get fresh content ' +
1428
- 'and line numbers before editing. The file was NOT modified.';
1429
- break;
1430
- }
1431
- }
1432
- // ──────────────────────────────────────────────────────────
1433
- const content = fs.readFileSync(filePath, 'utf-8');
1434
- // Snapshot before edit
1435
- undoStack.push({ filePath, prevContent: content, operation: 'EDIT_FILE', timestamp: Date.now(), relativePath: message.filePath });
1436
- if (undoStack.length > 20)
1437
- undoStack.shift();
1438
- // Use the 9-layer fuzzy replacer cascade (ported from OpenCode)
1439
- const result = fuzzyReplace(content, message.target, message.replacement, message.replaceAll || false);
1440
- if ('error' in result) {
1441
- // Edit failed — remove the snapshot we just pushed
1442
- undoStack.pop();
1443
- response.error = result.error;
1444
- }
1445
- else {
1446
- // ── LAYER 5: JSX content validation (full-file) ──
1447
- // Check the RESULTING file for balance, not the replacement
1448
- // alone. Agents routinely replace partial JSX blocks where
1449
- // the surrounding file provides the closing brackets — checking
1450
- // the replacement in isolation caused almost every write to
1451
- // fail on first attempt even when the result was valid.
1452
- const _jsExts = ['.js', '.jsx', '.tsx', '.ts'];
1453
- if (_jsExts.some(ext => filePath.endsWith(ext))) {
1454
- const _resultContent = result.result;
1455
- const _strip = (s) => s
1456
- .replace(/`[^`]*`/g, '``')
1457
- .replace(/"(?:[^"\\]|\\.)*"/g, '""')
1458
- .replace(/\'(?:[^\'\\]|\\.)*\'/g, "''");
1459
- // ── Baseline: measure original file's brace debt ──
1460
- // If the file was already imbalanced (e.g. JSX comment with {,
1461
- // regex literal, or prior agent edit), use that as the floor.
1462
- const _origStripped = _strip(content);
1463
- const _origDebt = (_origStripped.match(/\{/g) || []).length - (_origStripped.match(/\}/g) || []).length;
1464
- const _origPDebt = (_origStripped.match(/\(/g) || []).length - (_origStripped.match(/\)/g) || []).length;
1465
- const _stripped = _strip(_resultContent);
1466
- const _bo = (_stripped.match(/\{/g) || []).length;
1467
- const _bc = (_stripped.match(/\}/g) || []).length;
1468
- const _po = (_stripped.match(/\(/g) || []).length;
1469
- const _pc = (_stripped.match(/\)/g) || []).length;
1470
- const _newDebt = _bo - _bc;
1471
- const _newPDebt = _po - _pc;
1472
- // Only fail if the edit makes the imbalance WORSE than baseline
1473
- if (Math.abs(_newDebt) > Math.abs(_origDebt) || Math.abs(_newPDebt) > Math.abs(_origPDebt)) {
1474
- undoStack.pop();
1475
- response.error = `JSX validation failed: resulting file has unbalanced syntax ` +
1476
- `(braces: ${_bo} open / ${_bc} close, parens: ${_po} open / ${_pc} close). ` +
1477
- `The file was NOT modified. Re-read the file and fix the replacement so the overall file stays balanced.`;
1478
- break;
1479
- }
1480
- }
1481
- // ─────────────────────────────────────────────────
1482
- await withFileLock(filePath, async () => {
1483
- fs.writeFileSync(filePath, result.result, 'utf-8');
1484
- // Auto-format after edit
1485
- const formatter = await autoFormat(filePath, projectPath);
1486
- response.data = {
1487
- success: true,
1488
- path: filePath,
1489
- strategy: result.strategy,
1490
- formatted: formatter,
1491
- undoAvailable: true,
1492
- };
1493
- const strategyLabel = result.strategy === 'exact' ? '' : chalk.dim(` [${result.strategy}]`);
1494
- const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
1495
- console.log(chalk.blue('ℹ') + ` Edited file: ${message.filePath}${strategyLabel}${formatLabel}` + chalk.dim(' [undo saved]'));
1496
- });
1497
- // ── LSP: Post-edit TypeScript diagnostics ─────────────────────
1498
- const _eLsp = await checkDiagnostics(filePath, projectPath);
1499
- if (_eLsp.ran && response.data) {
1500
- response.data.lspDiagnostics = _eLsp.diagnostics;
1501
- if (_eLsp.summary) {
1502
- response.data.lspSummary = _eLsp.summary;
1503
- if (_eLsp.diagnostics.some(d => d.severity === 'error')) {
1504
- console.log(chalk.yellow('⚠') + ` ${_eLsp.summary}`);
1505
- }
1506
- }
1507
- }
1508
- // ──────────────────────────────────────────────────────────────
1509
- }
1510
- }
1511
- }
1512
- catch (e) {
1513
- response.error = e.message;
1514
- }
1242
+ }
1243
+ const rawContent = fs4.readFileSync(filePath, "utf-8");
1244
+ const allLines = rawContent.split("\n");
1245
+ const offset = message.offset || 1;
1246
+ const limit = message.limit || 2e3;
1247
+ const startIdx = Math.max(0, offset - 1);
1248
+ const endIdx = Math.min(allLines.length, startIdx + limit);
1249
+ const sliced = allLines.slice(startIdx, endIdx);
1250
+ const numbered = sliced.map((line, i) => `${startIdx + i + 1}: ${line}`).join("\n");
1251
+ const truncated = endIdx < allLines.length;
1252
+ const _rtStat = fs4.statSync(filePath);
1253
+ const _readToken = `${Math.round(_rtStat.mtimeMs)}-${_rtStat.size}`;
1254
+ response.data = {
1255
+ content: numbered,
1256
+ // rawContent intentionally omitted agents use line-numbered
1257
+ // content for edits; sending raw doubles context size per read.
1258
+ exists: true,
1259
+ path: message.filePath,
1260
+ totalLines: allLines.length,
1261
+ showing: { from: offset, to: endIdx, truncated },
1262
+ readToken: _readToken
1263
+ };
1264
+ } else {
1265
+ const dir = path4.dirname(filePath);
1266
+ const base = path4.basename(filePath);
1267
+ let suggestions = [];
1268
+ try {
1269
+ suggestions = fs4.readdirSync(dir).filter((e) => e.toLowerCase().includes(base.toLowerCase()) || base.toLowerCase().includes(e.toLowerCase())).slice(0, 3).map((e) => path4.relative(projectPath, path4.join(dir, e)));
1270
+ } catch (_) {
1271
+ }
1272
+ response.data = { exists: false, suggestions };
1273
+ }
1274
+ } catch (e) {
1275
+ response.error = e.message;
1276
+ }
1277
+ break;
1278
+ case "GET_SOURCE":
1279
+ try {
1280
+ const filePath = path4.resolve(projectPath, message.filePath);
1281
+ if (!filePath.startsWith(projectPath)) {
1282
+ response.error = "Access denied";
1283
+ } else if (fs4.existsSync(filePath)) {
1284
+ const content = fs4.readFileSync(filePath, "utf-8");
1285
+ const lines = content.split("\n");
1286
+ const start = Math.max(0, (message.lineStart || 1) - 1);
1287
+ const end = message.lineEnd ? Math.min(lines.length, message.lineEnd) : lines.length;
1288
+ response.data = {
1289
+ lines: lines.slice(start, end),
1290
+ startLine: start + 1,
1291
+ endLine: end,
1292
+ totalLines: lines.length
1293
+ };
1294
+ } else {
1295
+ response.error = "File not found";
1296
+ }
1297
+ } catch (e) {
1298
+ response.error = e.message;
1299
+ }
1300
+ break;
1301
+ case "GET_ERROR_CONTEXT":
1302
+ try {
1303
+ const filePath = path4.resolve(projectPath, message.filePath);
1304
+ if (!filePath.startsWith(projectPath)) {
1305
+ response.error = "Access denied";
1306
+ } else if (fs4.existsSync(filePath)) {
1307
+ const content = fs4.readFileSync(filePath, "utf-8");
1308
+ const lines = content.split("\n");
1309
+ const targetLine = message.line || 1;
1310
+ const contextLines = message.contextLines || 5;
1311
+ const start = Math.max(0, targetLine - contextLines - 1);
1312
+ const end = Math.min(lines.length, targetLine + contextLines);
1313
+ response.data = {
1314
+ lines: lines.slice(start, end).map((line, i) => ({
1315
+ number: start + i + 1,
1316
+ content: line,
1317
+ isError: start + i + 1 === targetLine
1318
+ })),
1319
+ errorLine: targetLine,
1320
+ filePath: message.filePath
1321
+ };
1322
+ } else {
1323
+ response.error = "File not found";
1324
+ }
1325
+ } catch (e) {
1326
+ response.error = e.message;
1327
+ }
1328
+ break;
1329
+ case "GET_FILE_TREE":
1330
+ try {
1331
+ const depth = message.depth || 3;
1332
+ const tree = getFileTree(projectPath, depth);
1333
+ response.data = { tree };
1334
+ } catch (e) {
1335
+ response.error = e.message;
1336
+ }
1337
+ break;
1338
+ case "SEARCH_CODE":
1339
+ try {
1340
+ const { matches, engine } = await searchCode(
1341
+ projectPath,
1342
+ message.query,
1343
+ {
1344
+ isRegex: message.isRegex || false,
1345
+ caseSensitive: message.caseSensitive !== false,
1346
+ maxResults: message.maxResults || 20
1347
+ }
1348
+ );
1349
+ response.data = { matches, engine };
1350
+ } catch (e) {
1351
+ response.error = e.message;
1352
+ }
1353
+ break;
1354
+ case "FIND_FILES":
1355
+ try {
1356
+ let findFiles2 = function(dir) {
1357
+ if (found.length >= maxResults)
1358
+ return;
1359
+ try {
1360
+ const items = fs4.readdirSync(dir);
1361
+ for (const item of items) {
1362
+ if (found.length >= maxResults)
1515
1363
  break;
1516
- case 'REPLACE_LINES':
1517
- try {
1518
- const filePath = path.resolve(projectPath, message.filePath);
1519
- if (!filePath.startsWith(projectPath)) {
1520
- response.error = 'Access denied: Path outside project';
1521
- }
1522
- else if (!fs.existsSync(filePath)) {
1523
- response.error = 'File not found: ' + message.filePath;
1524
- }
1525
- else {
1526
- // ── READ TOKEN DRIFT GUARD ──────────────────────────────────
1527
- // Stale line numbers are the primary failure mode of
1528
- // REPLACE_LINES. readToken proves the file is exactly
1529
- // the version the agent read before computing its range.
1530
- if (message.readToken) {
1531
- const _rStat = fs.statSync(filePath);
1532
- const _rToken = `${Math.round(_rStat.mtimeMs)}-${_rStat.size}`;
1533
- if (_rToken !== message.readToken) {
1534
- response.error =
1535
- 'File has changed since last read (readToken mismatch). ' +
1536
- 'Re-read the file with read_project_file to get fresh line numbers ' +
1537
- 'before replacing. The file was NOT modified.';
1538
- break;
1539
- }
1540
- }
1541
- // ────────────────────────────────────────────────────────────
1542
- const content = fs.readFileSync(filePath, 'utf-8');
1543
- const lines = content.split('\n');
1544
- const startLine = Math.max(1, message.startLine || 1);
1545
- const endLine = Math.min(lines.length, message.endLine || startLine);
1546
- if (startLine > lines.length) {
1547
- response.error = `Start line ${startLine} is beyond file end (${lines.length} lines)`;
1548
- }
1549
- else if (startLine > endLine) {
1550
- response.error = `Invalid range: start (${startLine}) > end (${endLine})`;
1551
- }
1552
- else {
1553
- // Snapshot for undo
1554
- undoStack.push({
1555
- filePath,
1556
- prevContent: content,
1557
- operation: 'REPLACE_LINES',
1558
- timestamp: Date.now(),
1559
- relativePath: message.filePath
1560
- });
1561
- if (undoStack.length > 20)
1562
- undoStack.shift();
1563
- const oldLineCount = endLine - startLine + 1;
1564
- const newLines = (message.newContent || '').split('\n');
1565
- // ── LAYER 5: JSX content validation (full-file + range hint) ──
1566
- // Validates the RESULTING file. If unbalanced because the
1567
- // deleted range has unclosed syntax, scans forward to find
1568
- // the precise suggested end_line — agent fixes in ONE retry.
1569
- const _jsExts2 = ['.js', '.jsx', '.tsx', '.ts'];
1570
- if (_jsExts2.some(ext => filePath.endsWith(ext))) {
1571
- const _strip = (s) => s
1572
- .replace(/`[^`]*`/g, '``')
1573
- .replace(/"(?:[^"\\]|\\.)*"/g, '""')
1574
- .replace(/\'(?:[^\'\\]|\\.)*\'/g, "''");
1575
- // ── Baseline: measure original file's brace debt ──
1576
- const _origStripped2 = _strip(content);
1577
- const _origDebt2 = (_origStripped2.match(/\{/g) || []).length - (_origStripped2.match(/\}/g) || []).length;
1578
- const _origPDebt2 = (_origStripped2.match(/\(/g) || []).length - (_origStripped2.match(/\)/g) || []).length;
1579
- const _previewLines = [...lines];
1580
- const _previewNew = (message.newContent || '').split('\n');
1581
- _previewLines.splice(startLine - 1, endLine - startLine + 1, ..._previewNew);
1582
- const _stripped = _strip(_previewLines.join('\n'));
1583
- const _bo = (_stripped.match(/\{/g) || []).length;
1584
- const _bc = (_stripped.match(/\}/g) || []).length;
1585
- const _po = (_stripped.match(/\(/g) || []).length;
1586
- const _pc = (_stripped.match(/\)/g) || []).length;
1587
- const _newDebt2 = _bo - _bc;
1588
- const _newPDebt2 = _po - _pc;
1589
- // Only fail if the edit makes the imbalance WORSE than baseline
1590
- if (Math.abs(_newDebt2) > Math.abs(_origDebt2) || Math.abs(_newPDebt2) > Math.abs(_origPDebt2)) {
1591
- undoStack.pop();
1592
- // Determine if deleted lines are the source of imbalance
1593
- const _deletedBlock = lines.slice(startLine - 1, endLine).join('\n');
1594
- const _ds = _strip(_deletedBlock);
1595
- const _braceDebt = (_ds.match(/\{/g) || []).length - (_ds.match(/\}/g) || []).length;
1596
- const _parenDebt = (_ds.match(/\(/g) || []).length - (_ds.match(/\)/g) || []).length;
1597
- let _hint = '';
1598
- if (_braceDebt > 0 || _parenDebt > 0) {
1599
- // Scan forward line-by-line to find where the debt is repaid
1600
- let _bd = _braceDebt;
1601
- let _pd = _parenDebt;
1602
- let _sugEnd = endLine;
1603
- const _tail = lines.slice(endLine);
1604
- for (let _i = 0; _i < _tail.length; _i++) {
1605
- const _ls = _strip(_tail[_i]);
1606
- _bd -= (_ls.match(/\}/g) || []).length - (_ls.match(/\{/g) || []).length;
1607
- _pd -= (_ls.match(/\)/g) || []).length - (_ls.match(/\(/g) || []).length;
1608
- _sugEnd = endLine + _i + 1;
1609
- if (_bd <= 0 && _pd <= 0)
1610
- break;
1611
- }
1612
- _hint = ` The range ${startLine}-${endLine} has unclosed syntax.` +
1613
- ` Expand end_line to ~${_sugEnd} to include the closing syntax, then retry.`;
1614
- }
1615
- else {
1616
- _hint = ' Re-read the file and ensure the replacement keeps all JSX balanced.';
1617
- }
1618
- response.error = `JSX validation failed: resulting file has unbalanced syntax ` +
1619
- `(braces: ${_bo} open / ${_bc} close, parens: ${_po} open / ${_pc} close). ` +
1620
- `The file was NOT modified.${_hint}`;
1621
- break;
1622
- }
1623
- }
1624
- // ──────────────────────────────────────────────────────────────
1625
- // splice + write inside lock
1626
- await withFileLock(filePath, async () => {
1627
- lines.splice(startLine - 1, oldLineCount, ...newLines);
1628
- const newContent = lines.join('\n');
1629
- fs.writeFileSync(filePath, newContent, 'utf-8');
1630
- // Auto-format
1631
- const formatter = await autoFormat(filePath, projectPath);
1632
- const lineDelta = newLines.length - oldLineCount;
1633
- response.data = {
1634
- success: true,
1635
- path: filePath,
1636
- linesReplaced: `${startLine}-${endLine}`,
1637
- oldLineCount,
1638
- newLineCount: newLines.length,
1639
- lineDelta: lineDelta,
1640
- totalLines: lines.length,
1641
- formatted: formatter,
1642
- undoAvailable: true,
1643
- hint: lineDelta !== 0
1644
- ? `Line count changed by ${lineDelta > 0 ? '+' : ''}${lineDelta}. Adjust subsequent line numbers if making more edits.`
1645
- : null
1646
- };
1647
- const formatLabel = formatter ? chalk.dim(` (${formatter})`) : '';
1648
- console.log(chalk.blue('ℹ') + ` Replaced lines ${startLine}-${endLine} in ${message.filePath} (${oldLineCount}→${newLines.length} lines)${formatLabel}` + chalk.dim(' [undo saved]'));
1649
- });
1650
- // ── LSP: Post-replace TypeScript diagnostics ──────────────────
1651
- const _rLsp = await checkDiagnostics(filePath, projectPath);
1652
- if (_rLsp.ran && response.data) {
1653
- response.data.lspDiagnostics = _rLsp.diagnostics;
1654
- if (_rLsp.summary) {
1655
- response.data.lspSummary = _rLsp.summary;
1656
- if (_rLsp.diagnostics.some(d => d.severity === 'error')) {
1657
- console.log(chalk.yellow('⚠') + ` ${_rLsp.summary}`);
1658
- }
1659
- }
1660
- }
1661
- // ──────────────────────────────────────────────────────────────
1662
- }
1663
- }
1664
- }
1665
- catch (e) {
1666
- response.error = e.message;
1364
+ if (ignorePatterns.includes(item) || item.startsWith("."))
1365
+ continue;
1366
+ const fullPath = path4.join(dir, item);
1367
+ try {
1368
+ const stat = fs4.statSync(fullPath);
1369
+ if (stat.isDirectory()) {
1370
+ findFiles2(fullPath);
1371
+ } else {
1372
+ const matchByName = !fileName || item === fileName || pattern.includes("*") && item.endsWith(fileName);
1373
+ if (matchByName) {
1374
+ found.push(path4.relative(projectPath, fullPath));
1375
+ }
1667
1376
  }
1668
- break;
1669
- case 'UNDO_LAST':
1670
- try {
1671
- if (undoStack.length === 0) {
1672
- response.error = 'Nothing to undo — no file changes recorded in this session.';
1673
- }
1674
- else {
1675
- const snapshot = undoStack.pop();
1676
- if (snapshot.prevContent === null) {
1677
- // File was created from scratch — delete it
1678
- if (fs.existsSync(snapshot.filePath)) {
1679
- fs.unlinkSync(snapshot.filePath);
1680
- response.data = {
1681
- success: true,
1682
- undone: snapshot.operation,
1683
- file: snapshot.relativePath,
1684
- action: 'deleted (was new file)',
1685
- remaining: undoStack.length,
1686
- };
1687
- console.log(chalk.yellow('↩') + ` Undo: deleted ${snapshot.relativePath} (was a new file)`);
1688
- }
1689
- else {
1690
- response.data = {
1691
- success: true,
1692
- undone: snapshot.operation,
1693
- file: snapshot.relativePath,
1694
- action: 'already gone',
1695
- remaining: undoStack.length,
1696
- };
1697
- }
1698
- }
1699
- else {
1700
- // Restore previous content
1701
- fs.writeFileSync(snapshot.filePath, snapshot.prevContent, 'utf-8');
1702
- response.data = {
1703
- success: true,
1704
- undone: snapshot.operation,
1705
- file: snapshot.relativePath,
1706
- action: 'restored',
1707
- remaining: undoStack.length,
1708
- };
1709
- console.log(chalk.yellow('↩') + ` Undo: restored ${snapshot.relativePath} (reverted ${snapshot.operation})`);
1710
- }
1711
- }
1377
+ } catch (e) {
1378
+ }
1379
+ }
1380
+ } catch (e) {
1381
+ }
1382
+ };
1383
+ var findFiles = findFiles2;
1384
+ const pattern = message.pattern || "";
1385
+ const fileName = pattern.split("/").pop().replace(/\*\*/g, "").replace(/\*/g, "");
1386
+ const maxResults = message.maxResults || 20;
1387
+ const ignorePatterns = ["node_modules", ".git", "dist", "build", ".next", ".cache"];
1388
+ const found = [];
1389
+ findFiles2(projectPath);
1390
+ response.data = found;
1391
+ } catch (e) {
1392
+ response.error = e.message;
1393
+ }
1394
+ case "DETECT_PROJECT":
1395
+ try {
1396
+ const pkgPath = path4.join(projectPath, "package.json");
1397
+ let deps = {};
1398
+ let devDeps = {};
1399
+ let pkgName = "";
1400
+ if (fs4.existsSync(pkgPath)) {
1401
+ const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf-8"));
1402
+ deps = pkg.dependencies || {};
1403
+ devDeps = pkg.devDependencies || {};
1404
+ pkgName = pkg.name || "";
1405
+ }
1406
+ const allDeps = { ...deps, ...devDeps };
1407
+ let framework = "html";
1408
+ if (allDeps["next"])
1409
+ framework = "next.js";
1410
+ else if (allDeps["nuxt"] || allDeps["nuxt3"])
1411
+ framework = "nuxt";
1412
+ else if (allDeps["gatsby"])
1413
+ framework = "gatsby";
1414
+ else if (allDeps["@remix-run/react"])
1415
+ framework = "remix";
1416
+ else if (allDeps["svelte"] || allDeps["@sveltejs/kit"])
1417
+ framework = "svelte";
1418
+ else if (allDeps["vue"])
1419
+ framework = "vue";
1420
+ else if (allDeps["@angular/core"])
1421
+ framework = "angular";
1422
+ else if (allDeps["react"])
1423
+ framework = "react";
1424
+ const typescript = !!allDeps["typescript"] || fs4.existsSync(path4.join(projectPath, "tsconfig.json"));
1425
+ const ext = typescript ? ".tsx" : ".jsx";
1426
+ let styling = "plain-css";
1427
+ if (allDeps["tailwindcss"])
1428
+ styling = "tailwind";
1429
+ else if (allDeps["styled-components"])
1430
+ styling = "styled-components";
1431
+ else if (allDeps["@emotion/react"] || allDeps["@emotion/styled"])
1432
+ styling = "emotion";
1433
+ else if (allDeps["sass"] || allDeps["node-sass"])
1434
+ styling = "sass";
1435
+ let router = null;
1436
+ if (framework === "next.js") {
1437
+ if (fs4.existsSync(path4.join(projectPath, "src/app")) || fs4.existsSync(path4.join(projectPath, "app"))) {
1438
+ router = "app";
1439
+ } else if (fs4.existsSync(path4.join(projectPath, "src/pages")) || fs4.existsSync(path4.join(projectPath, "pages"))) {
1440
+ router = "pages";
1441
+ }
1442
+ }
1443
+ const findFirst = (candidates) => {
1444
+ for (const c of candidates) {
1445
+ if (fs4.existsSync(path4.join(projectPath, c)))
1446
+ return c;
1447
+ }
1448
+ return null;
1449
+ };
1450
+ const layoutFile = findFirst([
1451
+ "src/app/layout.tsx",
1452
+ "src/app/layout.jsx",
1453
+ "src/app/layout.js",
1454
+ "app/layout.tsx",
1455
+ "app/layout.jsx",
1456
+ "app/layout.js",
1457
+ "src/pages/_app.tsx",
1458
+ "src/pages/_app.jsx",
1459
+ "pages/_app.tsx",
1460
+ "pages/_app.jsx",
1461
+ "src/App.tsx",
1462
+ "src/App.jsx",
1463
+ "src/App.js",
1464
+ "src/main.tsx",
1465
+ "src/main.jsx",
1466
+ "src/main.js",
1467
+ "index.html"
1468
+ ]);
1469
+ const globalStyleFile = findFirst([
1470
+ "src/app/globals.css",
1471
+ "src/app/global.css",
1472
+ "app/globals.css",
1473
+ "app/global.css",
1474
+ "src/index.css",
1475
+ "src/styles/globals.css",
1476
+ "src/styles/global.css",
1477
+ "src/App.css",
1478
+ "src/styles.css",
1479
+ "styles/globals.css",
1480
+ "styles/global.css",
1481
+ "css/style.css",
1482
+ "css/main.css",
1483
+ "style.css",
1484
+ "styles.css"
1485
+ ]);
1486
+ let componentsDir = findFirst([
1487
+ "src/components",
1488
+ "src/app/components",
1489
+ "components",
1490
+ "src/ui",
1491
+ "src/app/ui"
1492
+ ]);
1493
+ let tailwindConfig = null;
1494
+ let tailwindVersion = null;
1495
+ if (styling === "tailwind") {
1496
+ tailwindConfig = findFirst([
1497
+ "tailwind.config.ts",
1498
+ "tailwind.config.js",
1499
+ "tailwind.config.mjs",
1500
+ "tailwind.config.cjs"
1501
+ ]);
1502
+ tailwindVersion = allDeps["tailwindcss"] || null;
1503
+ }
1504
+ let componentPattern = "arrow-function";
1505
+ let exportPattern = "default";
1506
+ let sampleComponentFile = null;
1507
+ let sampleComponentCode = null;
1508
+ if (componentsDir) {
1509
+ const compDir = path4.join(projectPath, componentsDir);
1510
+ try {
1511
+ const files = fs4.readdirSync(compDir).filter(
1512
+ (f) => f.endsWith(".tsx") || f.endsWith(".jsx") || f.endsWith(".js") || f.endsWith(".vue") || f.endsWith(".svelte")
1513
+ );
1514
+ if (files.length > 0) {
1515
+ sampleComponentFile = path4.join(componentsDir, files[0]);
1516
+ const code = fs4.readFileSync(path4.join(compDir, files[0]), "utf-8");
1517
+ sampleComponentCode = code.split("\n").slice(0, 80).join("\n");
1518
+ if (/^export default function /m.test(code)) {
1519
+ componentPattern = "function-declaration";
1520
+ exportPattern = "default";
1521
+ } else if (/^export function /m.test(code)) {
1522
+ componentPattern = "function-declaration";
1523
+ exportPattern = "named";
1524
+ } else if (/const \w+ = \(/.test(code) || /const \w+ = \(\) =>/.test(code)) {
1525
+ componentPattern = "arrow-function";
1526
+ }
1527
+ if (/^export default /m.test(code))
1528
+ exportPattern = "default";
1529
+ else if (/^export \{/m.test(code) || /^export const/m.test(code))
1530
+ exportPattern = "named";
1531
+ if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(code)) {
1532
+ styling = "css-modules";
1533
+ }
1534
+ }
1535
+ } catch (e) {
1536
+ }
1537
+ }
1538
+ if (styling === "plain-css" && layoutFile) {
1539
+ try {
1540
+ const layoutCode = fs4.readFileSync(path4.join(projectPath, layoutFile), "utf-8");
1541
+ if (/import\s+\w+\s+from\s+['"][^'"]+\.module\.(css|scss|sass)['"]/.test(layoutCode)) {
1542
+ styling = "css-modules";
1543
+ }
1544
+ } catch (e) {
1545
+ }
1546
+ }
1547
+ let globalStylePreview = null;
1548
+ if (globalStyleFile) {
1549
+ try {
1550
+ const css = fs4.readFileSync(path4.join(projectPath, globalStyleFile), "utf-8");
1551
+ globalStylePreview = css.split("\n").slice(0, 40).join("\n");
1552
+ } catch (e) {
1553
+ }
1554
+ }
1555
+ const SKIP_DIRS = /* @__PURE__ */ new Set([
1556
+ "node_modules",
1557
+ ".git",
1558
+ ".next",
1559
+ ".nuxt",
1560
+ ".svelte-kit",
1561
+ "dist",
1562
+ "build",
1563
+ ".cache",
1564
+ ".turbo",
1565
+ "coverage",
1566
+ "__pycache__",
1567
+ ".vercel",
1568
+ ".output",
1569
+ ".parcel-cache"
1570
+ ]);
1571
+ const UI_EXTENSIONS = /* @__PURE__ */ new Set([
1572
+ ".tsx",
1573
+ ".jsx",
1574
+ ".js",
1575
+ ".ts",
1576
+ ".vue",
1577
+ ".svelte",
1578
+ ".css",
1579
+ ".scss",
1580
+ ".sass",
1581
+ ".less",
1582
+ ".module.css",
1583
+ ".module.scss",
1584
+ ".html",
1585
+ ".astro"
1586
+ ]);
1587
+ const treeLines = [];
1588
+ const MAX_TREE_LINES = 120;
1589
+ const dirComponentCount = /* @__PURE__ */ new Map();
1590
+ const buildTree = (dir, prefix, depth) => {
1591
+ if (depth > 4 || treeLines.length >= MAX_TREE_LINES)
1592
+ return;
1593
+ try {
1594
+ const entries = fs4.readdirSync(path4.join(projectPath, dir), { withFileTypes: true });
1595
+ const sorted = entries.filter((e) => !e.name.startsWith(".") || e.name === ".env").sort((a, b) => {
1596
+ if (a.isDirectory() && !b.isDirectory())
1597
+ return -1;
1598
+ if (!a.isDirectory() && b.isDirectory())
1599
+ return 1;
1600
+ return a.name.localeCompare(b.name);
1601
+ });
1602
+ for (let i = 0; i < sorted.length && treeLines.length < MAX_TREE_LINES; i++) {
1603
+ const entry = sorted[i];
1604
+ const isLast = i === sorted.length - 1;
1605
+ const connector = isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 ";
1606
+ const childPrefix = isLast ? " " : "\u2502 ";
1607
+ const childPath = dir ? `${dir}/${entry.name}` : entry.name;
1608
+ if (entry.isDirectory()) {
1609
+ if (SKIP_DIRS.has(entry.name))
1610
+ continue;
1611
+ treeLines.push(`${prefix}${connector}${entry.name}/`);
1612
+ buildTree(childPath, prefix + childPrefix, depth + 1);
1613
+ } else {
1614
+ const ext2 = path4.extname(entry.name).toLowerCase();
1615
+ const COMPONENT_EXTS = /* @__PURE__ */ new Set([".tsx", ".jsx", ".vue", ".svelte"]);
1616
+ if (COMPONENT_EXTS.has(ext2) && /^[A-Z]/.test(entry.name)) {
1617
+ dirComponentCount.set(dir, (dirComponentCount.get(dir) ?? 0) + 1);
1712
1618
  }
1713
- catch (e) {
1714
- response.error = e.message;
1619
+ if (UI_EXTENSIONS.has(ext2) || depth <= 1 && (entry.name === "package.json" || entry.name === "tsconfig.json" || entry.name.startsWith("tailwind.config") || entry.name.startsWith("next.config") || entry.name.startsWith("vite.config"))) {
1620
+ treeLines.push(`${prefix}${connector}${entry.name}`);
1715
1621
  }
1716
- break;
1717
- case 'DELETE_FILE':
1622
+ }
1623
+ }
1624
+ } catch (e) {
1625
+ }
1626
+ };
1627
+ const scanRoots = ["src", "app", "pages", "components", "styles", "public", "lib", "utils"];
1628
+ const existingRoots = [];
1629
+ for (const root of scanRoots) {
1630
+ if (fs4.existsSync(path4.join(projectPath, root))) {
1631
+ existingRoots.push(root);
1632
+ }
1633
+ }
1634
+ try {
1635
+ const rootEntries = fs4.readdirSync(projectPath, { withFileTypes: true });
1636
+ for (const entry of rootEntries) {
1637
+ if (!entry.isDirectory() && (entry.name === "package.json" || entry.name === "tsconfig.json" || entry.name.startsWith("tailwind.config") || entry.name.startsWith("next.config") || entry.name.startsWith("vite.config") || entry.name === "index.html")) {
1638
+ treeLines.push(entry.name);
1639
+ }
1640
+ }
1641
+ } catch (e) {
1642
+ }
1643
+ for (const root of existingRoots) {
1644
+ treeLines.push(`${root}/`);
1645
+ buildTree(root, "", 1);
1646
+ }
1647
+ const projectTree = treeLines.length > 0 ? treeLines.join("\n") : null;
1648
+ if (dirComponentCount.size > 0) {
1649
+ let bestDir = "";
1650
+ let bestCount = 0;
1651
+ for (const [dir, count] of dirComponentCount.entries()) {
1652
+ if (count > bestCount) {
1653
+ bestCount = count;
1654
+ bestDir = dir;
1655
+ }
1656
+ }
1657
+ if (bestCount > 0)
1658
+ componentsDir = bestDir;
1659
+ }
1660
+ response.data = {
1661
+ framework,
1662
+ typescript,
1663
+ styling,
1664
+ router,
1665
+ ext,
1666
+ pkgName,
1667
+ // Key file locations
1668
+ layoutFile,
1669
+ globalStyleFile,
1670
+ componentsDir,
1671
+ tailwindConfig,
1672
+ tailwindVersion,
1673
+ // Code conventions
1674
+ componentPattern,
1675
+ exportPattern,
1676
+ sampleComponentFile,
1677
+ sampleComponentCode,
1678
+ // CSS context
1679
+ globalStylePreview,
1680
+ // Scaffolding tree
1681
+ projectTree
1682
+ };
1683
+ console.log(chalk.blue("\u2139") + ` Project detected: ${chalk.yellow(framework)} + ${chalk.cyan(styling)}${typescript ? chalk.dim(" (TS)") : ""}${router ? chalk.dim(` [${router} router]`) : ""}`);
1684
+ } catch (e) {
1685
+ response.error = e.message;
1686
+ }
1687
+ break;
1688
+ case "BUILD_CLASS_INDEX":
1689
+ try {
1690
+ const COMPONENT_EXTS_IDX = /* @__PURE__ */ new Set([".tsx", ".jsx", ".vue", ".svelte", ".js", ".ts"]);
1691
+ const SKIP_DIRS_IDX = /* @__PURE__ */ new Set(["node_modules", ".git", ".next", ".nuxt", "dist", "build", ".cache"]);
1692
+ const index = {};
1693
+ let filesScanned = 0;
1694
+ let classesIndexed = 0;
1695
+ const scanDir = (dir) => {
1696
+ try {
1697
+ const entries = fs4.readdirSync(dir, { withFileTypes: true });
1698
+ for (const entry of entries) {
1699
+ if (entry.name.startsWith("."))
1700
+ continue;
1701
+ const fullPath = path4.join(dir, entry.name);
1702
+ const relPath = path4.relative(projectPath, fullPath).replace(/\\/g, "/");
1703
+ if (entry.isDirectory()) {
1704
+ if (SKIP_DIRS_IDX.has(entry.name))
1705
+ continue;
1706
+ scanDir(fullPath);
1707
+ } else {
1708
+ const ext = path4.extname(entry.name).toLowerCase();
1709
+ if (!COMPONENT_EXTS_IDX.has(ext))
1710
+ continue;
1718
1711
  try {
1719
- const filePath = path.resolve(projectPath, message.filePath);
1720
- if (!filePath.startsWith(projectPath)) {
1721
- response.error = 'Access denied: Path outside project';
1722
- break;
1723
- }
1724
- if (!fs.existsSync(filePath)) {
1725
- response.error = 'File not found: ' + message.filePath;
1726
- break;
1727
- }
1728
- // ── PROTECTED FILES BLOCKLIST ───────────────────────────
1729
- // These files are structurally critical to the project.
1730
- // The agent is NEVER allowed to delete them, even if asked.
1731
- const relPath = message.filePath.replace(/\\/g, '/');
1732
- const baseName = path.basename(relPath);
1733
- const PROTECTED_PATTERNS = [
1734
- // Package & lock files
1735
- 'package.json', 'package-lock.json', 'yarn.lock',
1736
- 'pnpm-lock.yaml', 'bun.lockb', 'bun.lock',
1737
- // TypeScript / JS config
1738
- 'tsconfig.json', 'tsconfig.node.json', 'jsconfig.json',
1739
- // Framework configs
1740
- /^next\.config\./, /^vite\.config\./,
1741
- /^nuxt\.config\./, /^svelte\.config\./,
1742
- /^remix\.config\./, /^astro\.config\./,
1743
- /^webpack\.config\./, /^babel\.config\./,
1744
- /^tailwind\.config\./, /^postcss\.config\./,
1745
- /^prettier\.config\./, /^eslint\.config\./,
1746
- '.eslintrc', '.prettierrc', '.babelrc',
1747
- // Env files
1748
- '.env', '.env.local', '.env.production',
1749
- '.env.development', '.env.staging',
1750
- // Next.js/React layout & entry roots
1751
- /^layout\.(tsx|jsx|js|ts)$/,
1752
- /^_app\.(tsx|jsx|js|ts)$/,
1753
- /^_document\.(tsx|jsx|js|ts)$/,
1754
- /^App\.(tsx|jsx|js|ts)$/,
1755
- /^main\.(tsx|jsx|js|ts)$/,
1756
- // Dockerfile & CI
1757
- 'Dockerfile', 'docker-compose.yml', 'docker-compose.yaml',
1758
- '.gitignore', '.gitattributes',
1759
- ];
1760
- const isProtected = PROTECTED_PATTERNS.some(p => typeof p === 'string' ? baseName === p || relPath.endsWith('/' + p)
1761
- : p.test(baseName));
1762
- if (isProtected) {
1763
- response.error = `🛡 Protected file: "${message.filePath}" cannot be deleted by the agent. This file is structurally critical to the project. To resolve conflicts involving this file, edit its contents instead of deleting it.`;
1764
- break;
1765
- }
1766
- // ────────────────────────────────────────────────────────
1767
- // ── SOFT DELETE → .trace-trash/ ────────────────────────
1768
- // Instead of hard-deleting, move the file to .trace-trash/
1769
- // with a timestamp prefix. This survives CLI restarts and
1770
- // undo-stack evictions — the file is always recoverable
1771
- // from the filesystem even after the session ends.
1772
- const trashDir = path.join(projectPath, '.trace-trash');
1773
- if (!fs.existsSync(trashDir)) {
1774
- fs.mkdirSync(trashDir, { recursive: true });
1775
- // Add to .gitignore if not already there
1776
- const gitignorePath = path.join(projectPath, '.gitignore');
1777
- if (fs.existsSync(gitignorePath)) {
1778
- const gi = fs.readFileSync(gitignorePath, 'utf-8');
1779
- if (!gi.includes('.trace-trash')) {
1780
- fs.appendFileSync(gitignorePath, '\n# Trace soft-delete recovery folder\n.trace-trash/\n');
1781
- }
1712
+ const content = fs4.readFileSync(fullPath, "utf-8");
1713
+ const lines = content.split("\n");
1714
+ filesScanned++;
1715
+ for (let i = 0; i < lines.length; i++) {
1716
+ const line = lines[i];
1717
+ const classMatches = line.matchAll(
1718
+ /(?:className|class)\s*=\s*(?:"([^"]+)"|'([^']+)'|\{(?:`([^`]+)`|"([^"]+)"|'([^']+)')\})/g
1719
+ );
1720
+ for (const match of classMatches) {
1721
+ const classValue = match[1] || match[2] || match[3] || match[4] || match[5];
1722
+ if (!classValue)
1723
+ continue;
1724
+ const classes = classValue.split(/\s+/).filter(
1725
+ (c) => c.length > 2 && !c.startsWith("$") && !c.includes("{") && !c.includes("}")
1726
+ );
1727
+ if (classes.length === 0)
1728
+ continue;
1729
+ const sig = classes.slice(0, 3).sort().join(" ");
1730
+ if (sig && !index[sig]) {
1731
+ index[sig] = { file: relPath, line: i + 1 };
1732
+ classesIndexed++;
1733
+ }
1734
+ for (const cls of classes) {
1735
+ if (/^(flex|grid|block|hidden|relative|absolute|fixed|sticky|inline|w-|h-|p-|m-|text-|bg-|border|rounded|flex-|grid-|col-|row-)/.test(cls))
1736
+ continue;
1737
+ if (cls.length > 5 && !index[cls]) {
1738
+ index[cls] = { file: relPath, line: i + 1 };
1739
+ classesIndexed++;
1782
1740
  }
1741
+ }
1783
1742
  }
1784
- const timestamp = Date.now();
1785
- const trashName = `${timestamp}_${baseName}`;
1786
- const trashPath = path.join(trashDir, trashName);
1787
- // Snapshot for in-session UNDO_LAST restore
1788
- const prevContent = fs.readFileSync(filePath, 'utf-8');
1789
- undoStack.push({ filePath, prevContent, operation: 'DELETE_FILE', timestamp, relativePath: message.filePath });
1790
- if (undoStack.length > 20)
1791
- undoStack.shift();
1792
- // Move to trash (not hard-delete)
1793
- fs.renameSync(filePath, trashPath);
1794
- // ────────────────────────────────────────────────────────
1795
- response.data = {
1796
- success: true,
1797
- deleted: message.filePath,
1798
- recoveryPath: path.relative(projectPath, trashPath),
1799
- undoAvailable: true,
1800
- hint: 'File moved to .trace-trash/ — recoverable even after CLI restart. Call UNDO_LAST to restore to original path.'
1801
- };
1802
- console.log(chalk.yellow('🗑') + ` Soft-deleted: ${message.filePath} → .trace-trash/${trashName}` + chalk.dim(' [recoverable]'));
1803
- }
1804
- catch (e) {
1805
- response.error = e.message;
1806
- }
1807
- break;
1808
- case 'RENAME_FILE':
1809
- try {
1810
- const oldPath = path.resolve(projectPath, message.oldPath);
1811
- const newPath = path.resolve(projectPath, message.newPath);
1812
- if (!oldPath.startsWith(projectPath) || !newPath.startsWith(projectPath)) {
1813
- response.error = 'Access denied: Path outside project';
1814
- }
1815
- else if (!fs.existsSync(oldPath)) {
1816
- response.error = 'File not found: ' + message.oldPath;
1817
- }
1818
- else if (fs.existsSync(newPath)) {
1819
- response.error = 'Target already exists: ' + message.newPath + '. Delete it first or choose a different name.';
1820
- }
1821
- else {
1822
- // Snapshot the content at the old path so UNDO_LAST can restore it
1823
- const prevContent = fs.readFileSync(oldPath, 'utf-8');
1824
- undoStack.push({ filePath: oldPath, prevContent, operation: 'RENAME_FILE', timestamp: Date.now(), relativePath: message.oldPath });
1825
- if (undoStack.length > 20)
1826
- undoStack.shift();
1827
- // Ensure destination directory exists
1828
- const destDir = path.dirname(newPath);
1829
- if (!fs.existsSync(destDir))
1830
- fs.mkdirSync(destDir, { recursive: true });
1831
- fs.renameSync(oldPath, newPath);
1832
- response.data = {
1833
- success: true,
1834
- oldPath: message.oldPath,
1835
- newPath: message.newPath,
1836
- undoAvailable: true,
1837
- };
1838
- console.log(chalk.blue('ℹ') + ` Renamed: ${message.oldPath} → ${message.newPath}` + chalk.dim(' [undo saved]'));
1839
- }
1840
- }
1841
- catch (e) {
1842
- response.error = e.message;
1743
+ }
1744
+ } catch (e) {
1843
1745
  }
1844
- break;
1845
- // ========== GIT & ENVIRONMENT TOOLS ==========
1846
- case 'GIT_BLAME':
1847
- try {
1848
- const filePath = message.filePath;
1849
- const line = message.line;
1850
- const { stdout } = await execAsync(`git blame -L ${line},${line} --porcelain "${filePath}"`, { cwd: projectPath });
1851
- const lines = stdout.split('\n');
1852
- const commitHash = lines[0].split(' ')[0];
1853
- const author = lines.find((l) => l.startsWith('author '))?.substring(7);
1854
- const email = lines.find((l) => l.startsWith('author-mail '))?.substring(12);
1855
- const date = lines.find((l) => l.startsWith('author-time '))?.substring(12);
1856
- const summary = lines.find((l) => l.startsWith('summary '))?.substring(8);
1857
- response.data = {
1858
- commit: commitHash,
1859
- author,
1860
- email,
1861
- date: new Date(parseInt(date) * 1000).toISOString().split('T')[0],
1862
- message: summary
1863
- };
1864
- }
1865
- catch (e) {
1866
- response.error = `Git blame failed: ${e.message}`;
1867
- }
1868
- break;
1869
- case 'GIT_RECENT_CHANGES':
1870
- try {
1871
- const filePath = message.filePath;
1872
- const days = message.days || 7;
1873
- const { stdout } = await execAsync(`git log -n 10 --since="${days} days ago" --pretty=format:"%h|%an|%ad|%s" --date=short "${filePath}"`, { cwd: projectPath });
1874
- response.data = {
1875
- history: stdout.split('\n').filter(Boolean).map((line) => {
1876
- const [hash, author, date, message] = line.split('|');
1877
- return { hash, author, date, message };
1878
- })
1879
- };
1880
- }
1881
- catch (e) {
1882
- response.error = `Git log failed: ${e.message}`;
1746
+ }
1747
+ }
1748
+ } catch (e) {
1749
+ }
1750
+ };
1751
+ scanDir(projectPath);
1752
+ response.data = {
1753
+ index,
1754
+ filesScanned,
1755
+ classesIndexed
1756
+ };
1757
+ console.log(chalk.blue("\u2139") + ` Class index built: ${chalk.yellow(classesIndexed)} classes across ${chalk.cyan(filesScanned)} files`);
1758
+ } catch (e) {
1759
+ response.error = e.message;
1760
+ }
1761
+ break;
1762
+ case "WRITE_FILE":
1763
+ try {
1764
+ const filePath = path4.resolve(projectPath, message.filePath);
1765
+ if (!filePath.startsWith(projectPath)) {
1766
+ response.error = "Access denied: Path outside project";
1767
+ } else {
1768
+ const prevContent = fs4.existsSync(filePath) ? fs4.readFileSync(filePath, "utf-8") : null;
1769
+ undoStack.push({ filePath, prevContent, operation: "WRITE_FILE", timestamp: Date.now(), relativePath: message.filePath });
1770
+ if (undoStack.length > 20)
1771
+ undoStack.shift();
1772
+ if (filePath.endsWith(".css")) {
1773
+ const writeContent = message.content || "";
1774
+ const opens = (writeContent.match(/\{/g) || []).length;
1775
+ const closes = (writeContent.match(/\}/g) || []).length;
1776
+ if (opens !== closes) {
1777
+ undoStack.pop();
1778
+ response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before writing.`;
1779
+ break;
1780
+ }
1781
+ }
1782
+ const dirPath = path4.dirname(filePath);
1783
+ if (!fs4.existsSync(dirPath)) {
1784
+ fs4.mkdirSync(dirPath, { recursive: true });
1785
+ }
1786
+ await withFileLock(filePath, async () => {
1787
+ fs4.writeFileSync(filePath, message.content, "utf-8");
1788
+ const formatter = await autoFormat(filePath, projectPath);
1789
+ response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
1790
+ console.log(chalk.blue("\u2139") + ` Wrote file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : "") + chalk.dim(" [undo saved]"));
1791
+ });
1792
+ const _wLsp = await checkDiagnostics(filePath, projectPath);
1793
+ if (_wLsp.ran && response.data) {
1794
+ response.data.lspDiagnostics = _wLsp.diagnostics;
1795
+ if (_wLsp.summary) {
1796
+ response.data.lspSummary = _wLsp.summary;
1797
+ if (_wLsp.diagnostics.some((d) => d.severity === "error")) {
1798
+ console.log(chalk.yellow("\u26A0") + ` ${_wLsp.summary}`);
1799
+ }
1800
+ }
1801
+ }
1802
+ }
1803
+ } catch (e) {
1804
+ response.error = e.message;
1805
+ }
1806
+ break;
1807
+ case "APPEND_FILE":
1808
+ try {
1809
+ const filePath = path4.resolve(projectPath, message.filePath);
1810
+ if (!filePath.startsWith(projectPath)) {
1811
+ response.error = "Access denied: Path outside project";
1812
+ } else {
1813
+ const isCssFile = filePath.endsWith(".css");
1814
+ const newContent = message.content || "";
1815
+ if (isCssFile) {
1816
+ const opens = (newContent.match(/\{/g) || []).length;
1817
+ const closes = (newContent.match(/\}/g) || []).length;
1818
+ if (opens !== closes) {
1819
+ response.error = `CSS validation failed: unbalanced braces (${opens} opening vs ${closes} closing). Fix the CSS before appending.`;
1820
+ break;
1821
+ }
1822
+ }
1823
+ const prevContent = fs4.existsSync(filePath) ? fs4.readFileSync(filePath, "utf-8") : null;
1824
+ undoStack.push({ filePath, prevContent, operation: "APPEND_FILE", timestamp: Date.now(), relativePath: message.filePath });
1825
+ if (undoStack.length > 20)
1826
+ undoStack.shift();
1827
+ if (isCssFile && prevContent !== null) {
1828
+ const importRegex = /^@(?:import|charset)\s+[^;]+;\s*$/gm;
1829
+ const newImports = [];
1830
+ const bodyContent = newContent.replace(importRegex, (match) => {
1831
+ newImports.push(match.trim());
1832
+ return "";
1833
+ }).trim();
1834
+ if (newImports.length > 0) {
1835
+ const existingLines = prevContent.split("\n");
1836
+ let lastImportLineIdx = -1;
1837
+ for (let i = 0; i < existingLines.length; i++) {
1838
+ const trimmed = existingLines[i].trim();
1839
+ if (trimmed.startsWith("@import") || trimmed.startsWith("@charset")) {
1840
+ lastImportLineIdx = i;
1883
1841
  }
1884
- break;
1885
- case 'GET_IMPORTS':
1886
- try {
1887
- const filePath = path.resolve(projectPath, message.filePath);
1888
- if (fs.existsSync(filePath)) {
1889
- const content = fs.readFileSync(filePath, 'utf-8');
1890
- const importRegex = /import\s+(?:[\w*\s{},]*)\s+from\s+['"]([^'"]+)['"]/g;
1891
- const imports = [];
1892
- let match;
1893
- while ((match = importRegex.exec(content)) !== null) {
1894
- imports.push(match[1]);
1895
- }
1896
- response.data = { imports };
1897
- }
1898
- else {
1899
- response.error = 'File not found';
1900
- }
1842
+ if (trimmed && !trimmed.startsWith("@import") && !trimmed.startsWith("@charset") && !trimmed.startsWith("/*") && !trimmed.startsWith("*") && !trimmed.startsWith("//")) {
1843
+ break;
1901
1844
  }
1902
- catch (e) {
1903
- response.error = e.message;
1845
+ }
1846
+ const dedupedImports = newImports.filter((imp) => !prevContent.includes(imp));
1847
+ const insertIdx = lastImportLineIdx + 1;
1848
+ const topLines = existingLines.slice(0, insertIdx);
1849
+ const restLines = existingLines.slice(insertIdx);
1850
+ const merged = [
1851
+ ...topLines,
1852
+ ...dedupedImports,
1853
+ ...restLines,
1854
+ "",
1855
+ // separator
1856
+ bodyContent
1857
+ ].join("\n");
1858
+ fs4.writeFileSync(filePath, merged, "utf-8");
1859
+ console.log(chalk.blue("\u2139") + ` CSS-safe append: hoisted ${dedupedImports.length} @import(s) to top of ${message.filePath}`);
1860
+ } else {
1861
+ fs4.appendFileSync(filePath, "\n\n" + newContent, "utf-8");
1862
+ }
1863
+ } else {
1864
+ fs4.appendFileSync(filePath, "\n" + newContent, "utf-8");
1865
+ }
1866
+ const formatter = await autoFormat(filePath, projectPath);
1867
+ response.data = { success: true, path: filePath, formatted: formatter, undoAvailable: true };
1868
+ console.log(chalk.blue("\u2139") + ` Appended file: ${message.filePath}` + (formatter ? chalk.dim(` (formatted with ${formatter})`) : "") + chalk.dim(" [undo saved]"));
1869
+ }
1870
+ } catch (e) {
1871
+ response.error = e.message;
1872
+ }
1873
+ break;
1874
+ case "EDIT_CLASSNAME": {
1875
+ try {
1876
+ const filePath = path4.resolve(projectPath, message.filePath);
1877
+ if (!filePath.startsWith(projectPath)) {
1878
+ response.error = "Access denied: Path outside project";
1879
+ break;
1880
+ }
1881
+ if (!fs4.existsSync(filePath)) {
1882
+ response.error = "File not found: " + message.filePath;
1883
+ break;
1884
+ }
1885
+ if (message.readToken) {
1886
+ const stat = fs4.statSync(filePath);
1887
+ const token = `${Math.round(stat.mtimeMs)}-${stat.size}`;
1888
+ if (token !== message.readToken) {
1889
+ response.error = "File has changed since last read (readToken mismatch). Re-read with read_project_file before editing.";
1890
+ break;
1891
+ }
1892
+ }
1893
+ const source = fs4.readFileSync(filePath, "utf-8");
1894
+ undoStack.push({ filePath, prevContent: source, operation: "EDIT_CLASSNAME", timestamp: Date.now(), relativePath: message.filePath });
1895
+ if (undoStack.length > 20)
1896
+ undoStack.shift();
1897
+ const result = editJSXClassName(source, {
1898
+ oldValue: message.oldValue,
1899
+ newValue: message.newValue,
1900
+ lineHint: message.lineHint
1901
+ });
1902
+ if (!result.success) {
1903
+ undoStack.pop();
1904
+ response.error = result.error ?? "AST edit failed";
1905
+ break;
1906
+ }
1907
+ await withFileLock(filePath, async () => {
1908
+ fs4.writeFileSync(filePath, result.code, "utf-8");
1909
+ const formatter = await autoFormat(filePath, projectPath);
1910
+ response.data = {
1911
+ success: true,
1912
+ path: filePath,
1913
+ strategy: result.strategy,
1914
+ matchedLine: result.matchedLine,
1915
+ formatted: formatter,
1916
+ undoAvailable: true
1917
+ };
1918
+ console.log(
1919
+ chalk.blue("\u2139") + ` EDIT_CLASSNAME: ${message.filePath}` + chalk.dim(` [${result.strategy} @ line ${result.matchedLine}]`) + (formatter ? chalk.dim(` (${formatter})`) : "") + chalk.dim(" [undo saved]")
1920
+ );
1921
+ });
1922
+ const lsp = await checkDiagnostics(filePath, projectPath);
1923
+ if (lsp.ran && response.data) {
1924
+ response.data.lspDiagnostics = lsp.diagnostics;
1925
+ if (lsp.summary)
1926
+ response.data.lspSummary = lsp.summary;
1927
+ }
1928
+ } catch (e) {
1929
+ response.error = e.message;
1930
+ }
1931
+ break;
1932
+ }
1933
+ case "EDIT_FILE":
1934
+ try {
1935
+ const filePath = path4.resolve(projectPath, message.filePath);
1936
+ if (!filePath.startsWith(projectPath)) {
1937
+ response.error = "Access denied: Path outside project";
1938
+ } else if (!fs4.existsSync(filePath)) {
1939
+ const requestedBase = path4.basename(message.filePath);
1940
+ const requestedExt = path4.extname(requestedBase);
1941
+ const requestedStem = requestedBase.replace(requestedExt, "");
1942
+ const suggestions = [];
1943
+ const dirsToSearch = ["src/app", "app", "src/pages", "pages", "src/components", "components", "src"];
1944
+ for (const dir of dirsToSearch) {
1945
+ const absDir = path4.join(projectPath, dir);
1946
+ if (fs4.existsSync(absDir)) {
1947
+ try {
1948
+ const files = fs4.readdirSync(absDir, { recursive: true });
1949
+ for (const f of files.slice(0, 100)) {
1950
+ const fBase = path4.basename(f);
1951
+ if (fBase.startsWith(requestedStem + ".") || fBase === requestedBase) {
1952
+ suggestions.push(path4.join(dir, f).replace(/\\/g, "/"));
1953
+ }
1904
1954
  }
1955
+ } catch (e) {
1956
+ }
1957
+ }
1958
+ }
1959
+ const hint = suggestions.length > 0 ? " Did you mean: " + suggestions.slice(0, 3).join(" or ") + "?" : " Double-check the path against detect_project() output (layoutFile, globalStyleFile) or projectTree.";
1960
+ response.error = 'File not found: "' + message.filePath + '".' + hint;
1961
+ } else {
1962
+ if (message.readToken) {
1963
+ const _eStat = fs4.statSync(filePath);
1964
+ const _eToken = `${Math.round(_eStat.mtimeMs)}-${_eStat.size}`;
1965
+ if (_eToken !== message.readToken) {
1966
+ response.error = "File has changed since last read (readToken mismatch). Re-read the file with read_project_file to get fresh content and line numbers before editing. The file was NOT modified.";
1967
+ break;
1968
+ }
1969
+ }
1970
+ const content = fs4.readFileSync(filePath, "utf-8");
1971
+ undoStack.push({ filePath, prevContent: content, operation: "EDIT_FILE", timestamp: Date.now(), relativePath: message.filePath });
1972
+ if (undoStack.length > 20)
1973
+ undoStack.shift();
1974
+ const result = fuzzyReplace(content, message.target, message.replacement, message.replaceAll || false);
1975
+ if ("error" in result) {
1976
+ undoStack.pop();
1977
+ response.error = result.error;
1978
+ } else {
1979
+ const _jsExts = [".js", ".jsx", ".tsx", ".ts"];
1980
+ if (_jsExts.some((ext) => filePath.endsWith(ext))) {
1981
+ const _resultContent = result.result;
1982
+ const _strip = (s) => s.replace(/`[^`]*`/g, "``").replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/\'(?:[^\'\\]|\\.)*\'/g, "''");
1983
+ const _origStripped = _strip(content);
1984
+ const _origDebt = (_origStripped.match(/\{/g) || []).length - (_origStripped.match(/\}/g) || []).length;
1985
+ const _origPDebt = (_origStripped.match(/\(/g) || []).length - (_origStripped.match(/\)/g) || []).length;
1986
+ const _stripped = _strip(_resultContent);
1987
+ const _bo = (_stripped.match(/\{/g) || []).length;
1988
+ const _bc = (_stripped.match(/\}/g) || []).length;
1989
+ const _po = (_stripped.match(/\(/g) || []).length;
1990
+ const _pc = (_stripped.match(/\)/g) || []).length;
1991
+ const _newDebt = _bo - _bc;
1992
+ const _newPDebt = _po - _pc;
1993
+ if (Math.abs(_newDebt) > Math.abs(_origDebt) || Math.abs(_newPDebt) > Math.abs(_origPDebt)) {
1994
+ undoStack.pop();
1995
+ response.error = `JSX validation failed: resulting file has unbalanced syntax (braces: ${_bo} open / ${_bc} close, parens: ${_po} open / ${_pc} close). The file was NOT modified. Re-read the file and fix the replacement so the overall file stays balanced.`;
1905
1996
  break;
1906
- case 'FIND_USAGES':
1907
- try {
1908
- const query = message.query;
1909
- const { stdout } = await execAsync(`git grep -n "${query}"`, { cwd: projectPath });
1910
- response.data = {
1911
- usages: stdout.split('\n').filter(Boolean).slice(0, 20).map((line) => {
1912
- const parts = line.split(':');
1913
- return {
1914
- file: parts[0],
1915
- line: parseInt(parts[1]),
1916
- content: parts.slice(2).join(':').trim()
1917
- };
1918
- })
1919
- };
1997
+ }
1998
+ }
1999
+ await withFileLock(filePath, async () => {
2000
+ fs4.writeFileSync(filePath, result.result, "utf-8");
2001
+ const formatter = await autoFormat(filePath, projectPath);
2002
+ response.data = {
2003
+ success: true,
2004
+ path: filePath,
2005
+ strategy: result.strategy,
2006
+ formatted: formatter,
2007
+ undoAvailable: true
2008
+ };
2009
+ const strategyLabel = result.strategy === "exact" ? "" : chalk.dim(` [${result.strategy}]`);
2010
+ const formatLabel = formatter ? chalk.dim(` (${formatter})`) : "";
2011
+ console.log(chalk.blue("\u2139") + ` Edited file: ${message.filePath}${strategyLabel}${formatLabel}` + chalk.dim(" [undo saved]"));
2012
+ });
2013
+ const _eLsp = await checkDiagnostics(filePath, projectPath);
2014
+ if (_eLsp.ran && response.data) {
2015
+ response.data.lspDiagnostics = _eLsp.diagnostics;
2016
+ if (_eLsp.summary) {
2017
+ response.data.lspSummary = _eLsp.summary;
2018
+ if (_eLsp.diagnostics.some((d) => d.severity === "error")) {
2019
+ console.log(chalk.yellow("\u26A0") + ` ${_eLsp.summary}`);
1920
2020
  }
1921
- catch (e) {
1922
- if (e.code === 1)
1923
- response.data = { usages: [] };
1924
- else
1925
- response.error = `Grep failed: ${e.message}`;
2021
+ }
2022
+ }
2023
+ }
2024
+ }
2025
+ } catch (e) {
2026
+ response.error = e.message;
2027
+ }
2028
+ break;
2029
+ case "REPLACE_LINES":
2030
+ try {
2031
+ const filePath = path4.resolve(projectPath, message.filePath);
2032
+ if (!filePath.startsWith(projectPath)) {
2033
+ response.error = "Access denied: Path outside project";
2034
+ } else if (!fs4.existsSync(filePath)) {
2035
+ response.error = "File not found: " + message.filePath;
2036
+ } else {
2037
+ if (message.readToken) {
2038
+ const _rStat = fs4.statSync(filePath);
2039
+ const _rToken = `${Math.round(_rStat.mtimeMs)}-${_rStat.size}`;
2040
+ if (_rToken !== message.readToken) {
2041
+ response.error = "File has changed since last read (readToken mismatch). Re-read the file with read_project_file to get fresh line numbers before replacing. The file was NOT modified.";
2042
+ break;
2043
+ }
2044
+ }
2045
+ const content = fs4.readFileSync(filePath, "utf-8");
2046
+ const lines = content.split("\n");
2047
+ const startLine = Math.max(1, message.startLine || 1);
2048
+ const endLine = Math.min(lines.length, message.endLine || startLine);
2049
+ if (startLine > lines.length) {
2050
+ response.error = `Start line ${startLine} is beyond file end (${lines.length} lines)`;
2051
+ } else if (startLine > endLine) {
2052
+ response.error = `Invalid range: start (${startLine}) > end (${endLine})`;
2053
+ } else {
2054
+ undoStack.push({
2055
+ filePath,
2056
+ prevContent: content,
2057
+ operation: "REPLACE_LINES",
2058
+ timestamp: Date.now(),
2059
+ relativePath: message.filePath
2060
+ });
2061
+ if (undoStack.length > 20)
2062
+ undoStack.shift();
2063
+ const oldLineCount = endLine - startLine + 1;
2064
+ const newLines = (message.newContent || "").split("\n");
2065
+ const _jsExts2 = [".js", ".jsx", ".tsx", ".ts"];
2066
+ if (_jsExts2.some((ext) => filePath.endsWith(ext))) {
2067
+ const _strip = (s) => s.replace(/`[^`]*`/g, "``").replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/\'(?:[^\'\\]|\\.)*\'/g, "''");
2068
+ const _origStripped2 = _strip(content);
2069
+ const _origDebt2 = (_origStripped2.match(/\{/g) || []).length - (_origStripped2.match(/\}/g) || []).length;
2070
+ const _origPDebt2 = (_origStripped2.match(/\(/g) || []).length - (_origStripped2.match(/\)/g) || []).length;
2071
+ const _previewLines = [...lines];
2072
+ const _previewNew = (message.newContent || "").split("\n");
2073
+ _previewLines.splice(startLine - 1, endLine - startLine + 1, ..._previewNew);
2074
+ const _stripped = _strip(_previewLines.join("\n"));
2075
+ const _bo = (_stripped.match(/\{/g) || []).length;
2076
+ const _bc = (_stripped.match(/\}/g) || []).length;
2077
+ const _po = (_stripped.match(/\(/g) || []).length;
2078
+ const _pc = (_stripped.match(/\)/g) || []).length;
2079
+ const _newDebt2 = _bo - _bc;
2080
+ const _newPDebt2 = _po - _pc;
2081
+ if (Math.abs(_newDebt2) > Math.abs(_origDebt2) || Math.abs(_newPDebt2) > Math.abs(_origPDebt2)) {
2082
+ undoStack.pop();
2083
+ const _deletedBlock = lines.slice(startLine - 1, endLine).join("\n");
2084
+ const _ds = _strip(_deletedBlock);
2085
+ const _braceDebt = (_ds.match(/\{/g) || []).length - (_ds.match(/\}/g) || []).length;
2086
+ const _parenDebt = (_ds.match(/\(/g) || []).length - (_ds.match(/\)/g) || []).length;
2087
+ let _hint = "";
2088
+ if (_braceDebt > 0 || _parenDebt > 0) {
2089
+ let _bd = _braceDebt;
2090
+ let _pd = _parenDebt;
2091
+ let _sugEnd = endLine;
2092
+ const _tail = lines.slice(endLine);
2093
+ for (let _i = 0; _i < _tail.length; _i++) {
2094
+ const _ls = _strip(_tail[_i]);
2095
+ _bd -= (_ls.match(/\}/g) || []).length - (_ls.match(/\{/g) || []).length;
2096
+ _pd -= (_ls.match(/\)/g) || []).length - (_ls.match(/\(/g) || []).length;
2097
+ _sugEnd = endLine + _i + 1;
2098
+ if (_bd <= 0 && _pd <= 0)
2099
+ break;
2100
+ }
2101
+ _hint = ` The range ${startLine}-${endLine} has unclosed syntax. Expand end_line to ~${_sugEnd} to include the closing syntax, then retry.`;
2102
+ } else {
2103
+ _hint = " Re-read the file and ensure the replacement keeps all JSX balanced.";
1926
2104
  }
2105
+ response.error = `JSX validation failed: resulting file has unbalanced syntax (braces: ${_bo} open / ${_bc} close, parens: ${_po} open / ${_pc} close). The file was NOT modified.${_hint}`;
1927
2106
  break;
1928
- case 'GET_ENV_VARS':
1929
- try {
1930
- const filePath = path.resolve(projectPath, message.filePath);
1931
- if (fs.existsSync(filePath)) {
1932
- const content = fs.readFileSync(filePath, 'utf-8');
1933
- const envRegex = /(?:process\.env\.|import\.meta\.env\.)([A-Z_][A-Z0-9_]*)/g;
1934
- const vars = new Set();
1935
- let match;
1936
- while ((match = envRegex.exec(content)) !== null) {
1937
- vars.add(match[1]);
1938
- }
1939
- response.data = { envVars: Array.from(vars) };
1940
- }
1941
- else {
1942
- response.error = 'File not found';
1943
- }
1944
- }
1945
- catch (e) {
1946
- response.error = e.message;
2107
+ }
2108
+ }
2109
+ await withFileLock(filePath, async () => {
2110
+ lines.splice(startLine - 1, oldLineCount, ...newLines);
2111
+ const newContent = lines.join("\n");
2112
+ fs4.writeFileSync(filePath, newContent, "utf-8");
2113
+ const formatter = await autoFormat(filePath, projectPath);
2114
+ const lineDelta = newLines.length - oldLineCount;
2115
+ response.data = {
2116
+ success: true,
2117
+ path: filePath,
2118
+ linesReplaced: `${startLine}-${endLine}`,
2119
+ oldLineCount,
2120
+ newLineCount: newLines.length,
2121
+ lineDelta,
2122
+ totalLines: lines.length,
2123
+ formatted: formatter,
2124
+ undoAvailable: true,
2125
+ hint: lineDelta !== 0 ? `Line count changed by ${lineDelta > 0 ? "+" : ""}${lineDelta}. Adjust subsequent line numbers if making more edits.` : null
2126
+ };
2127
+ const formatLabel = formatter ? chalk.dim(` (${formatter})`) : "";
2128
+ console.log(chalk.blue("\u2139") + ` Replaced lines ${startLine}-${endLine} in ${message.filePath} (${oldLineCount}\u2192${newLines.length} lines)${formatLabel}` + chalk.dim(" [undo saved]"));
2129
+ });
2130
+ const _rLsp = await checkDiagnostics(filePath, projectPath);
2131
+ if (_rLsp.ran && response.data) {
2132
+ response.data.lspDiagnostics = _rLsp.diagnostics;
2133
+ if (_rLsp.summary) {
2134
+ response.data.lspSummary = _rLsp.summary;
2135
+ if (_rLsp.diagnostics.some((d) => d.severity === "error")) {
2136
+ console.log(chalk.yellow("\u26A0") + ` ${_rLsp.summary}`);
1947
2137
  }
1948
- break;
1949
- // ========== TERMINAL BUFFER ACCESS ==========
1950
- case 'GET_TERMINAL_BUFFER': {
1951
- // Returns the last N lines of the dev server terminal output.
1952
- // Agents use this to get compiler errors without running a build.
1953
- const lines = parseInt(message.lines) || 100;
1954
- const buffer = globalTerminalBuffer.getLast(lines);
1955
- response.data = {
1956
- lines: buffer,
1957
- totalLines: globalTerminalBuffer.length,
1958
- hasDevProcess: devProcess !== null && !devProcess.killed,
1959
- };
1960
- break;
2138
+ }
1961
2139
  }
1962
- case 'GET_DEV_STATUS': {
1963
- response.data = {
1964
- running: devProcess !== null && !devProcess.killed,
1965
- pid: devProcess?.pid ?? null,
1966
- };
1967
- break;
2140
+ }
2141
+ }
2142
+ } catch (e) {
2143
+ response.error = e.message;
2144
+ }
2145
+ break;
2146
+ case "UNDO_LAST":
2147
+ try {
2148
+ if (undoStack.length === 0) {
2149
+ response.error = "Nothing to undo \u2014 no file changes recorded in this session.";
2150
+ } else {
2151
+ const snapshot = undoStack.pop();
2152
+ if (snapshot.prevContent === null) {
2153
+ if (fs4.existsSync(snapshot.filePath)) {
2154
+ fs4.unlinkSync(snapshot.filePath);
2155
+ response.data = {
2156
+ success: true,
2157
+ undone: snapshot.operation,
2158
+ file: snapshot.relativePath,
2159
+ action: "deleted (was new file)",
2160
+ remaining: undoStack.length
2161
+ };
2162
+ console.log(chalk.yellow("\u21A9") + ` Undo: deleted ${snapshot.relativePath} (was a new file)`);
2163
+ } else {
2164
+ response.data = {
2165
+ success: true,
2166
+ undone: snapshot.operation,
2167
+ file: snapshot.relativePath,
2168
+ action: "already gone",
2169
+ remaining: undoStack.length
2170
+ };
2171
+ }
2172
+ } else {
2173
+ fs4.writeFileSync(snapshot.filePath, snapshot.prevContent, "utf-8");
2174
+ response.data = {
2175
+ success: true,
2176
+ undone: snapshot.operation,
2177
+ file: snapshot.relativePath,
2178
+ action: "restored",
2179
+ remaining: undoStack.length
2180
+ };
2181
+ console.log(chalk.yellow("\u21A9") + ` Undo: restored ${snapshot.relativePath} (reverted ${snapshot.operation})`);
2182
+ }
2183
+ }
2184
+ } catch (e) {
2185
+ response.error = e.message;
2186
+ }
2187
+ break;
2188
+ case "DELETE_FILE":
2189
+ try {
2190
+ const filePath = path4.resolve(projectPath, message.filePath);
2191
+ if (!filePath.startsWith(projectPath)) {
2192
+ response.error = "Access denied: Path outside project";
2193
+ break;
2194
+ }
2195
+ if (!fs4.existsSync(filePath)) {
2196
+ response.error = "File not found: " + message.filePath;
2197
+ break;
2198
+ }
2199
+ const relPath = message.filePath.replace(/\\/g, "/");
2200
+ const baseName = path4.basename(relPath);
2201
+ const PROTECTED_PATTERNS = [
2202
+ // Package & lock files
2203
+ "package.json",
2204
+ "package-lock.json",
2205
+ "yarn.lock",
2206
+ "pnpm-lock.yaml",
2207
+ "bun.lockb",
2208
+ "bun.lock",
2209
+ // TypeScript / JS config
2210
+ "tsconfig.json",
2211
+ "tsconfig.node.json",
2212
+ "jsconfig.json",
2213
+ // Framework configs
2214
+ /^next\.config\./,
2215
+ /^vite\.config\./,
2216
+ /^nuxt\.config\./,
2217
+ /^svelte\.config\./,
2218
+ /^remix\.config\./,
2219
+ /^astro\.config\./,
2220
+ /^webpack\.config\./,
2221
+ /^babel\.config\./,
2222
+ /^tailwind\.config\./,
2223
+ /^postcss\.config\./,
2224
+ /^prettier\.config\./,
2225
+ /^eslint\.config\./,
2226
+ ".eslintrc",
2227
+ ".prettierrc",
2228
+ ".babelrc",
2229
+ // Env files
2230
+ ".env",
2231
+ ".env.local",
2232
+ ".env.production",
2233
+ ".env.development",
2234
+ ".env.staging",
2235
+ // Next.js/React layout & entry roots
2236
+ /^layout\.(tsx|jsx|js|ts)$/,
2237
+ /^_app\.(tsx|jsx|js|ts)$/,
2238
+ /^_document\.(tsx|jsx|js|ts)$/,
2239
+ /^App\.(tsx|jsx|js|ts)$/,
2240
+ /^main\.(tsx|jsx|js|ts)$/,
2241
+ // Dockerfile & CI
2242
+ "Dockerfile",
2243
+ "docker-compose.yml",
2244
+ "docker-compose.yaml",
2245
+ ".gitignore",
2246
+ ".gitattributes"
2247
+ ];
2248
+ const isProtected = PROTECTED_PATTERNS.some(
2249
+ (p) => typeof p === "string" ? baseName === p || relPath.endsWith("/" + p) : p.test(baseName)
2250
+ );
2251
+ if (isProtected) {
2252
+ response.error = `\u{1F6E1} Protected file: "${message.filePath}" cannot be deleted by the agent. This file is structurally critical to the project. To resolve conflicts involving this file, edit its contents instead of deleting it.`;
2253
+ break;
2254
+ }
2255
+ const trashDir = path4.join(projectPath, ".trace-trash");
2256
+ if (!fs4.existsSync(trashDir)) {
2257
+ fs4.mkdirSync(trashDir, { recursive: true });
2258
+ const gitignorePath = path4.join(projectPath, ".gitignore");
2259
+ if (fs4.existsSync(gitignorePath)) {
2260
+ const gi = fs4.readFileSync(gitignorePath, "utf-8");
2261
+ if (!gi.includes(".trace-trash")) {
2262
+ fs4.appendFileSync(gitignorePath, "\n# Trace soft-delete recovery folder\n.trace-trash/\n");
1968
2263
  }
1969
- default:
1970
- response.error = `Unknown message type: ${type}`;
2264
+ }
2265
+ }
2266
+ const timestamp = Date.now();
2267
+ const trashName = `${timestamp}_${baseName}`;
2268
+ const trashPath = path4.join(trashDir, trashName);
2269
+ const prevContent = fs4.readFileSync(filePath, "utf-8");
2270
+ undoStack.push({ filePath, prevContent, operation: "DELETE_FILE", timestamp, relativePath: message.filePath });
2271
+ if (undoStack.length > 20)
2272
+ undoStack.shift();
2273
+ fs4.renameSync(filePath, trashPath);
2274
+ response.data = {
2275
+ success: true,
2276
+ deleted: message.filePath,
2277
+ recoveryPath: path4.relative(projectPath, trashPath),
2278
+ undoAvailable: true,
2279
+ hint: "File moved to .trace-trash/ \u2014 recoverable even after CLI restart. Call UNDO_LAST to restore to original path."
2280
+ };
2281
+ console.log(chalk.yellow("\u{1F5D1}") + ` Soft-deleted: ${message.filePath} \u2192 .trace-trash/${trashName}` + chalk.dim(" [recoverable]"));
2282
+ } catch (e) {
2283
+ response.error = e.message;
2284
+ }
2285
+ break;
2286
+ case "RENAME_FILE":
2287
+ try {
2288
+ const oldPath = path4.resolve(projectPath, message.oldPath);
2289
+ const newPath = path4.resolve(projectPath, message.newPath);
2290
+ if (!oldPath.startsWith(projectPath) || !newPath.startsWith(projectPath)) {
2291
+ response.error = "Access denied: Path outside project";
2292
+ } else if (!fs4.existsSync(oldPath)) {
2293
+ response.error = "File not found: " + message.oldPath;
2294
+ } else if (fs4.existsSync(newPath)) {
2295
+ response.error = "Target already exists: " + message.newPath + ". Delete it first or choose a different name.";
2296
+ } else {
2297
+ const prevContent = fs4.readFileSync(oldPath, "utf-8");
2298
+ undoStack.push({ filePath: oldPath, prevContent, operation: "RENAME_FILE", timestamp: Date.now(), relativePath: message.oldPath });
2299
+ if (undoStack.length > 20)
2300
+ undoStack.shift();
2301
+ const destDir = path4.dirname(newPath);
2302
+ if (!fs4.existsSync(destDir))
2303
+ fs4.mkdirSync(destDir, { recursive: true });
2304
+ fs4.renameSync(oldPath, newPath);
2305
+ response.data = {
2306
+ success: true,
2307
+ oldPath: message.oldPath,
2308
+ newPath: message.newPath,
2309
+ undoAvailable: true
2310
+ };
2311
+ console.log(chalk.blue("\u2139") + ` Renamed: ${message.oldPath} \u2192 ${message.newPath}` + chalk.dim(" [undo saved]"));
2312
+ }
2313
+ } catch (e) {
2314
+ response.error = e.message;
2315
+ }
2316
+ break;
2317
+ case "GIT_BLAME":
2318
+ try {
2319
+ const filePath = message.filePath;
2320
+ const line = message.line;
2321
+ const { stdout } = await execAsync3(`git blame -L ${line},${line} --porcelain "${filePath}"`, { cwd: projectPath });
2322
+ const lines = stdout.split("\n");
2323
+ const commitHash = lines[0].split(" ")[0];
2324
+ const author = lines.find((l) => l.startsWith("author "))?.substring(7);
2325
+ const email = lines.find((l) => l.startsWith("author-mail "))?.substring(12);
2326
+ const date = lines.find((l) => l.startsWith("author-time "))?.substring(12);
2327
+ const summary = lines.find((l) => l.startsWith("summary "))?.substring(8);
2328
+ response.data = {
2329
+ commit: commitHash,
2330
+ author,
2331
+ email,
2332
+ date: new Date(parseInt(date) * 1e3).toISOString().split("T")[0],
2333
+ message: summary
2334
+ };
2335
+ } catch (e) {
2336
+ response.error = `Git blame failed: ${e.message}`;
2337
+ }
2338
+ break;
2339
+ case "GIT_RECENT_CHANGES":
2340
+ try {
2341
+ const filePath = message.filePath;
2342
+ const days = message.days || 7;
2343
+ const { stdout } = await execAsync3(`git log -n 10 --since="${days} days ago" --pretty=format:"%h|%an|%ad|%s" --date=short "${filePath}"`, { cwd: projectPath });
2344
+ response.data = {
2345
+ history: stdout.split("\n").filter(Boolean).map((line) => {
2346
+ const [hash, author, date, message2] = line.split("|");
2347
+ return { hash, author, date, message: message2 };
2348
+ })
2349
+ };
2350
+ } catch (e) {
2351
+ response.error = `Git log failed: ${e.message}`;
2352
+ }
2353
+ break;
2354
+ case "GET_IMPORTS":
2355
+ try {
2356
+ const filePath = path4.resolve(projectPath, message.filePath);
2357
+ if (fs4.existsSync(filePath)) {
2358
+ const content = fs4.readFileSync(filePath, "utf-8");
2359
+ const importRegex = /import\s+(?:[\w*\s{},]*)\s+from\s+['"]([^'"]+)['"]/g;
2360
+ const imports = [];
2361
+ let match;
2362
+ while ((match = importRegex.exec(content)) !== null) {
2363
+ imports.push(match[1]);
2364
+ }
2365
+ response.data = { imports };
2366
+ } else {
2367
+ response.error = "File not found";
1971
2368
  }
1972
- ws.send(JSON.stringify(response));
2369
+ } catch (e) {
2370
+ response.error = e.message;
2371
+ }
2372
+ break;
2373
+ case "FIND_USAGES":
2374
+ try {
2375
+ const query = message.query;
2376
+ const { stdout } = await execAsync3(`git grep -n "${query}"`, { cwd: projectPath });
2377
+ response.data = {
2378
+ usages: stdout.split("\n").filter(Boolean).slice(0, 20).map((line) => {
2379
+ const parts = line.split(":");
2380
+ return {
2381
+ file: parts[0],
2382
+ line: parseInt(parts[1]),
2383
+ content: parts.slice(2).join(":").trim()
2384
+ };
2385
+ })
2386
+ };
2387
+ } catch (e) {
2388
+ if (e.code === 1)
2389
+ response.data = { usages: [] };
2390
+ else
2391
+ response.error = `Grep failed: ${e.message}`;
2392
+ }
2393
+ break;
2394
+ case "GET_ENV_VARS":
2395
+ try {
2396
+ const filePath = path4.resolve(projectPath, message.filePath);
2397
+ if (fs4.existsSync(filePath)) {
2398
+ const content = fs4.readFileSync(filePath, "utf-8");
2399
+ const envRegex = /(?:process\.env\.|import\.meta\.env\.)([A-Z_][A-Z0-9_]*)/g;
2400
+ const vars = /* @__PURE__ */ new Set();
2401
+ let match;
2402
+ while ((match = envRegex.exec(content)) !== null) {
2403
+ vars.add(match[1]);
2404
+ }
2405
+ response.data = { envVars: Array.from(vars) };
2406
+ } else {
2407
+ response.error = "File not found";
2408
+ }
2409
+ } catch (e) {
2410
+ response.error = e.message;
2411
+ }
2412
+ break;
2413
+ case "GET_TERMINAL_BUFFER": {
2414
+ const lines = parseInt(message.lines) || 100;
2415
+ const buffer = globalTerminalBuffer.getLast(lines);
2416
+ response.data = {
2417
+ lines: buffer,
2418
+ totalLines: globalTerminalBuffer.length,
2419
+ hasDevProcess: devProcess !== null && !devProcess.killed
2420
+ };
2421
+ break;
1973
2422
  }
1974
- catch (e) {
1975
- console.error(chalk.red('Parse error:'), e.message);
2423
+ case "GET_DEV_STATUS": {
2424
+ response.data = {
2425
+ running: devProcess !== null && !devProcess.killed,
2426
+ pid: devProcess?.pid ?? null
2427
+ };
2428
+ break;
1976
2429
  }
1977
- });
1978
- }
1979
- // ============================================
1980
- // COMMAND: trace connect (legacy / IDE-only)
1981
- // ============================================
1982
- program
1983
- .command('connect', { isDefault: true })
1984
- .alias('c')
1985
- .description('Start IDE bridge only (use "trace dev" to also start your dev server)')
1986
- .option('-p, --port <port>', 'WebSocket port', '8765')
1987
- .action(async (options) => {
1988
- const port = parseInt(options.port);
1989
- const projectPath = process.cwd();
1990
- console.log();
1991
- console.log(chalk.bold.cyan('🔗 Trace IDE Bridge'));
1992
- console.log(chalk.gray('─'.repeat(55)));
1993
- console.log();
1994
- console.log(`Project: ${chalk.green(projectPath)}`);
1995
- console.log(`Port: ${chalk.cyan(port)}`);
1996
- console.log();
2430
+ default:
2431
+ response.error = `Unknown message type: ${type}`;
2432
+ }
2433
+ ws.send(JSON.stringify(response));
2434
+ } catch (e) {
2435
+ console.error(chalk.red("Parse error:"), e.message);
2436
+ }
2437
+ });
2438
+ ws.on("message", (rawData) => {
1997
2439
  try {
1998
- const pkgPath = path.join(projectPath, 'package.json');
1999
- if (fs.existsSync(pkgPath)) {
2000
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
2001
- console.log(`📦 Package: ${chalk.yellow(pkg.name)} v${pkg.version}`);
2002
- }
2440
+ const msg = JSON.parse(rawData.toString());
2441
+ if (msg.type === "RESPONSE" && msg.id && globalBrowserPending.has(msg.id)) {
2442
+ const { resolve: resolve3 } = globalBrowserPending.get(msg.id);
2443
+ globalBrowserPending.delete(msg.id);
2444
+ resolve3(msg.error ? { error: msg.error } : msg.data ?? {});
2445
+ }
2446
+ } catch {
2003
2447
  }
2004
- catch (_) { }
2005
- const wss = new WebSocketServer({ port });
2006
- let clientCount = 0;
2007
- console.log();
2008
- console.log(chalk.green('✓') + ' WebSocket server started');
2009
- console.log(chalk.dim('Waiting for extension to connect...'));
2010
- console.log();
2011
- console.log(chalk.gray('─'.repeat(55)));
2012
- console.log(chalk.dim('Press Ctrl+C to stop'));
2013
- console.log();
2014
- wss.on('connection', (ws) => {
2015
- clientCount++;
2016
- console.log(chalk.green('●') + ` Extension connected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
2017
- // Wire up the full IDE bridge protocol
2018
- attachMessageHandler(ws, projectPath);
2019
- ws.on('close', () => {
2020
- clientCount--;
2021
- console.log(chalk.yellow('●') + ` Extension disconnected (${clientCount} client${clientCount > 1 ? 's' : ''})`);
2022
- });
2023
- ws.on('error', (error) => {
2024
- console.error(chalk.red('WebSocket error:'), error.message);
2025
- });
2026
- });
2027
- wss.on('error', (error) => {
2028
- if (error.code === 'EADDRINUSE') {
2029
- console.log(chalk.red(`✗ Port ${port} is already in use`));
2030
- console.log(chalk.dim('Try: trace-connect --port 8766'));
2031
- }
2032
- else {
2033
- console.error(chalk.red('Server error:'), error.message);
2034
- }
2035
- process.exit(1);
2448
+ });
2449
+ }
2450
+ program.command("connect", { isDefault: true }).alias("c").description('Start IDE bridge only (use "trace dev" to also start your dev server)').option("-p, --port <port>", "WebSocket port", "8765").action(async (options) => {
2451
+ const port = parseInt(options.port);
2452
+ const projectPath = process.cwd();
2453
+ console.log();
2454
+ console.log(chalk.bold.cyan("\u{1F517} Trace IDE Bridge"));
2455
+ console.log(chalk.gray("\u2500".repeat(55)));
2456
+ console.log();
2457
+ console.log(`Project: ${chalk.green(projectPath)}`);
2458
+ console.log(`Port: ${chalk.cyan(port)}`);
2459
+ console.log();
2460
+ try {
2461
+ const pkgPath = path4.join(projectPath, "package.json");
2462
+ if (fs4.existsSync(pkgPath)) {
2463
+ const pkg = JSON.parse(fs4.readFileSync(pkgPath, "utf-8"));
2464
+ console.log(`\u{1F4E6} Package: ${chalk.yellow(pkg.name)} v${pkg.version}`);
2465
+ }
2466
+ } catch (_) {
2467
+ }
2468
+ const wss = new WebSocketServer({ port });
2469
+ let clientCount = 0;
2470
+ console.log();
2471
+ console.log(chalk.green("\u2713") + " WebSocket server started");
2472
+ console.log(chalk.dim("Waiting for extension to connect..."));
2473
+ console.log();
2474
+ console.log(chalk.gray("\u2500".repeat(55)));
2475
+ console.log(chalk.dim("Press Ctrl+C to stop"));
2476
+ console.log();
2477
+ wss.on("connection", (ws) => {
2478
+ clientCount++;
2479
+ console.log(chalk.green("\u25CF") + ` Extension connected (${clientCount} client${clientCount > 1 ? "s" : ""})`);
2480
+ attachMessageHandler(ws, projectPath);
2481
+ ws.on("close", () => {
2482
+ clientCount--;
2483
+ console.log(chalk.yellow("\u25CF") + ` Extension disconnected (${clientCount} client${clientCount > 1 ? "s" : ""})`);
2036
2484
  });
2037
- process.on('SIGINT', () => {
2038
- console.log();
2039
- console.log(chalk.dim('Stopping...'));
2040
- wss.close();
2041
- process.exit(0);
2485
+ ws.on("error", (error) => {
2486
+ console.error(chalk.red("WebSocket error:"), error.message);
2042
2487
  });
2488
+ });
2489
+ wss.on("error", (error) => {
2490
+ if (error.code === "EADDRINUSE") {
2491
+ console.log(chalk.red(`\u2717 Port ${port} is already in use`));
2492
+ console.log(chalk.dim("Try: trace-connect --port 8766"));
2493
+ } else {
2494
+ console.error(chalk.red("Server error:"), error.message);
2495
+ }
2496
+ process.exit(1);
2497
+ });
2498
+ process.on("SIGINT", () => {
2499
+ console.log();
2500
+ console.log(chalk.dim("Stopping..."));
2501
+ wss.close();
2502
+ process.exit(0);
2503
+ });
2043
2504
  });
2044
2505
  program.parse(process.argv);
2045
- // Helper: Get file tree
2046
2506
  function getFileTree(dir, depth, currentDepth = 0) {
2047
- if (currentDepth >= depth)
2048
- return null;
2049
- const result = {};
2050
- const ignorePatterns = ['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.cache'];
2051
- try {
2052
- const items = fs.readdirSync(dir);
2053
- for (const item of items.slice(0, 50)) {
2054
- if (ignorePatterns.includes(item) || item.startsWith('.'))
2055
- continue;
2056
- const fullPath = path.join(dir, item);
2057
- try {
2058
- const stat = fs.statSync(fullPath);
2059
- if (stat.isDirectory()) {
2060
- result[item] = getFileTree(fullPath, depth, currentDepth + 1);
2061
- }
2062
- else {
2063
- result[item] = 'file';
2064
- }
2065
- }
2066
- catch (e) {
2067
- // Skip inaccessible
2068
- }
2069
- }
2070
- }
2071
- catch (e) {
2072
- // Skip inaccessible dirs
2073
- }
2074
- return result;
2075
- }
2076
- // Helper: Simple search in files
2077
- function searchInFiles(dir, query, maxResults) {
2078
- const results = [];
2079
- const extensions = ['.js', '.ts', '.jsx', '.tsx', '.vue', '.svelte', '.css', '.html', '.json'];
2080
- const ignorePatterns = ['node_modules', '.git', 'dist', 'build', '.next', '.cache'];
2081
- function searchDir(currentDir) {
2082
- if (results.length >= maxResults)
2083
- return;
2084
- try {
2085
- const items = fs.readdirSync(currentDir);
2086
- for (const item of items) {
2087
- if (results.length >= maxResults)
2088
- return;
2089
- if (ignorePatterns.includes(item) || item.startsWith('.'))
2090
- continue;
2091
- const fullPath = path.join(currentDir, item);
2092
- try {
2093
- const stat = fs.statSync(fullPath);
2094
- if (stat.isDirectory()) {
2095
- searchDir(fullPath);
2096
- }
2097
- else if (extensions.some(ext => item.endsWith(ext))) {
2098
- const content = fs.readFileSync(fullPath, 'utf-8');
2099
- const lines = content.split('\n');
2100
- for (let i = 0; i < lines.length && results.length < maxResults; i++) {
2101
- if (lines[i].includes(query)) {
2102
- results.push({
2103
- file: path.relative(dir, fullPath),
2104
- line: i + 1,
2105
- content: lines[i].trim().substring(0, 200)
2106
- });
2107
- }
2108
- }
2109
- }
2110
- }
2111
- catch (e) {
2112
- // ignore
2113
- }
2114
- }
2115
- }
2116
- catch (e) {
2117
- // ignore
2507
+ if (currentDepth >= depth)
2508
+ return null;
2509
+ const result = {};
2510
+ const ignorePatterns = ["node_modules", ".git", "dist", "build", ".next", "coverage", ".cache"];
2511
+ try {
2512
+ const items = fs4.readdirSync(dir);
2513
+ for (const item of items.slice(0, 50)) {
2514
+ if (ignorePatterns.includes(item) || item.startsWith("."))
2515
+ continue;
2516
+ const fullPath = path4.join(dir, item);
2517
+ try {
2518
+ const stat = fs4.statSync(fullPath);
2519
+ if (stat.isDirectory()) {
2520
+ result[item] = getFileTree(fullPath, depth, currentDepth + 1);
2521
+ } else {
2522
+ result[item] = "file";
2118
2523
  }
2524
+ } catch (e) {
2525
+ }
2119
2526
  }
2120
- searchDir(dir);
2121
- return results;
2527
+ } catch (e) {
2528
+ }
2529
+ return result;
2122
2530
  }
2123
- //# sourceMappingURL=index.js.map