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