@illuma-ai/code-sandbox 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,775 +0,0 @@
1
- /**
2
- * NodepodRuntime — Manages the full lifecycle of a Nodepod sandbox.
3
- *
4
- * Flow: boot → write files → npm install → run entry command → track changes
5
- *
6
- * This is the core service that bridges project files and the browser runtime.
7
- * It is framework-agnostic (no React) — consumed by the useRuntime hook.
8
- */
9
-
10
- import { Nodepod } from "@illuma-ai/nodepod";
11
- import type { NodepodProcess } from "@illuma-ai/nodepod";
12
- import type {
13
- BootProgress,
14
- BootStage,
15
- FileMap,
16
- RuntimeConfig,
17
- SandboxError,
18
- SandboxErrorCategory,
19
- } from "../types";
20
-
21
- /** Callback type for progress updates */
22
- type ProgressCallback = (progress: BootProgress) => void;
23
- /** Callback type for terminal output lines */
24
- type OutputCallback = (line: string) => void;
25
- /** Callback type for server ready */
26
- type ServerReadyCallback = (port: number, url: string) => void;
27
- /** Callback type for structured errors */
28
- type ErrorCallback = (error: SandboxError) => void;
29
-
30
- /** Debug log prefix for easy filtering in DevTools console */
31
- const DBG = "[CodeSandbox:Runtime]";
32
-
33
- /**
34
- * Manages the Nodepod runtime lifecycle.
35
- *
36
- * Responsibilities:
37
- * - Boot Nodepod with COOP/COEP-compatible service worker
38
- * - Write project files into the virtual filesystem
39
- * - Install npm dependencies from package.json
40
- * - Start the entry command (e.g., "node server.js")
41
- * - Track file modifications for diffing
42
- * - Provide preview URL for the iframe
43
- */
44
- export class NodepodRuntime {
45
- private nodepod: Nodepod | null = null;
46
- private serverProcess: NodepodProcess | null = null;
47
- private config: RuntimeConfig;
48
- private originalFiles: FileMap = {};
49
- private currentFiles: FileMap = {};
50
- private terminalOutput: string[] = [];
51
- private status: BootStage = "initializing";
52
- private error: string | null = null;
53
- private previewUrl: string | null = null;
54
- private errors: SandboxError[] = [];
55
- private errorIdCounter = 0;
56
-
57
- // Callbacks
58
- private onProgress: ProgressCallback | null = null;
59
- private onOutput: OutputCallback | null = null;
60
- private onServerReady: ServerReadyCallback | null = null;
61
- private onSandboxError: ErrorCallback | null = null;
62
-
63
- constructor(config: RuntimeConfig) {
64
- this.config = {
65
- workdir: "/app",
66
- port: 3000,
67
- ...config,
68
- };
69
- console.log(DBG, "constructor", {
70
- workdir: this.config.workdir,
71
- port: this.config.port,
72
- entryCommand: this.config.entryCommand,
73
- fileCount: Object.keys(this.config.files).length,
74
- files: Object.keys(this.config.files),
75
- });
76
- }
77
-
78
- /** Register a progress callback */
79
- setProgressCallback(cb: ProgressCallback): void {
80
- this.onProgress = cb;
81
- }
82
-
83
- /** Register a terminal output callback */
84
- setOutputCallback(cb: OutputCallback): void {
85
- this.onOutput = cb;
86
- }
87
-
88
- /** Register a server ready callback */
89
- setServerReadyCallback(cb: ServerReadyCallback): void {
90
- this.onServerReady = cb;
91
- }
92
-
93
- /** Register a structured error callback */
94
- setErrorCallback(cb: ErrorCallback): void {
95
- this.onSandboxError = cb;
96
- }
97
-
98
- /** Get the current preview URL (null if server not ready) */
99
- getPreviewUrl(): string | null {
100
- return this.previewUrl;
101
- }
102
-
103
- /** Get current status */
104
- getStatus(): BootStage {
105
- return this.status;
106
- }
107
-
108
- /** Get all terminal output */
109
- getTerminalOutput(): string[] {
110
- return [...this.terminalOutput];
111
- }
112
-
113
- /** Get current files (with edits applied) */
114
- getCurrentFiles(): FileMap {
115
- return { ...this.currentFiles };
116
- }
117
-
118
- /** Get the original files (as loaded, before any edits) */
119
- getOriginalFiles(): FileMap {
120
- return { ...this.originalFiles };
121
- }
122
-
123
- /** Get only the files that have been modified since loading */
124
- getChangedFiles(): FileMap {
125
- const changes: FileMap = {};
126
- for (const [path, content] of Object.entries(this.currentFiles)) {
127
- if (this.originalFiles[path] !== content) {
128
- changes[path] = content;
129
- }
130
- }
131
- return changes;
132
- }
133
-
134
- /** Get the error message if status is 'error' */
135
- getError(): string | null {
136
- return this.error;
137
- }
138
-
139
- /** Get all structured errors collected during this session */
140
- getErrors(): SandboxError[] {
141
- return [...this.errors];
142
- }
143
-
144
- /**
145
- * Report an external error (e.g., from the preview iframe).
146
- *
147
- * This is the public entry point for errors that originate outside
148
- * the runtime — browser console errors, unhandled rejections, etc.
149
- * The runtime enriches them with source context from the virtual FS
150
- * and emits them via the onSandboxError callback.
151
- */
152
- async reportError(error: SandboxError): Promise<void> {
153
- // Enrich with source context if we have a file path and line number
154
- if (error.filePath && error.line && !error.sourceContext) {
155
- error.sourceContext = await this.getSourceContext(
156
- error.filePath,
157
- error.line,
158
- );
159
- }
160
- this.errors.push(error);
161
- this.onSandboxError?.(error);
162
- }
163
-
164
- // -------------------------------------------------------------------------
165
- // Lifecycle
166
- // -------------------------------------------------------------------------
167
-
168
- /**
169
- * Boot the runtime: initialize Nodepod → write files → install → start server.
170
- *
171
- * This is the main entry point. Call this once.
172
- * The progress callback will fire at each stage.
173
- */
174
- async boot(): Promise<void> {
175
- const t0 = performance.now();
176
- console.log(DBG, "boot() started");
177
-
178
- try {
179
- // Stage 1: Initialize Nodepod
180
- this.emitProgress("initializing", "Booting runtime environment...", 5);
181
-
182
- const port = this.config.port ?? 3000;
183
- console.log(DBG, "boot: calling Nodepod.boot()", {
184
- swUrl: "/__sw__.js",
185
- workdir: this.config.workdir,
186
- port,
187
- });
188
-
189
- // Check preconditions Nodepod will check
190
- console.log(
191
- DBG,
192
- "boot: SharedArrayBuffer available?",
193
- typeof SharedArrayBuffer !== "undefined",
194
- );
195
- console.log(
196
- DBG,
197
- "boot: Worker available?",
198
- typeof Worker !== "undefined",
199
- );
200
-
201
- const tBoot = performance.now();
202
- this.nodepod = await Nodepod.boot({
203
- swUrl: "/__sw__.js",
204
- workdir: this.config.workdir,
205
- env: this.config.env,
206
- onServerReady: (readyPort: number, url: string) => {
207
- console.log(DBG, "onServerReady fired!", {
208
- readyPort,
209
- url,
210
- expectedPort: port,
211
- });
212
- if (readyPort === port) {
213
- this.previewUrl = this.rewritePreviewUrl(url);
214
- console.log(DBG, "previewUrl set to:", this.previewUrl);
215
- this.emitProgress("ready", "Application is running", 100);
216
- this.onServerReady?.(readyPort, this.previewUrl);
217
- }
218
- },
219
- });
220
- console.log(
221
- DBG,
222
- `boot: Nodepod.boot() completed in ${(performance.now() - tBoot).toFixed(0)}ms`,
223
- );
224
-
225
- this.emitProgress("initializing", "Runtime environment ready", 15);
226
-
227
- // Stage 2: Write project files
228
- this.emitProgress("writing-files", "Writing project files...", 20);
229
- const tFiles = performance.now();
230
- await this.writeFiles(this.config.files);
231
- console.log(
232
- DBG,
233
- `boot: writeFiles completed in ${(performance.now() - tFiles).toFixed(0)}ms`,
234
- );
235
- this.emitProgress(
236
- "writing-files",
237
- `Wrote ${Object.keys(this.config.files).length} files`,
238
- 35,
239
- );
240
-
241
- // Verify files were actually written
242
- try {
243
- const workdir = this.config.workdir ?? "/app";
244
- const testFile = Object.keys(this.config.files)[0];
245
- if (testFile) {
246
- const fullPath = testFile.startsWith("/")
247
- ? testFile
248
- : `${workdir}/${testFile}`;
249
- const exists = await this.nodepod.fs.exists(fullPath);
250
- console.log(DBG, `boot: verify file "${fullPath}" exists:`, exists);
251
- if (exists) {
252
- const content = await this.nodepod.fs.readFile(fullPath, "utf-8");
253
- console.log(
254
- DBG,
255
- `boot: verify file "${fullPath}" has ${content.length} chars`,
256
- );
257
- }
258
- }
259
- } catch (verifyErr) {
260
- console.warn(DBG, "boot: file verification failed:", verifyErr);
261
- }
262
-
263
- // Stage 3: Install dependencies (if package.json exists)
264
- if (this.config.files["package.json"]) {
265
- this.emitProgress("installing", "Installing dependencies...", 40);
266
- const tInstall = performance.now();
267
- await this.installDependencies();
268
- console.log(
269
- DBG,
270
- `boot: installDependencies completed in ${(performance.now() - tInstall).toFixed(0)}ms`,
271
- );
272
- this.emitProgress("installing", "Dependencies installed", 70);
273
- } else {
274
- console.log(DBG, "boot: no package.json found, skipping install");
275
- }
276
-
277
- // Stage 4: Start the entry command
278
- this.emitProgress("starting", "Starting application...", 75);
279
- const tStart = performance.now();
280
- await this.startServer();
281
- console.log(
282
- DBG,
283
- `boot: startServer returned in ${(performance.now() - tStart).toFixed(0)}ms`,
284
- );
285
- console.log(
286
- DBG,
287
- `boot: total elapsed ${(performance.now() - t0).toFixed(0)}ms, status=${this.status}`,
288
- );
289
-
290
- // Note: 'ready' is emitted by the onServerReady callback above.
291
- // If the server doesn't register a port (e.g., non-HTTP scripts),
292
- // we mark ready after a short delay.
293
- // Also poll more aggressively: check at 1s, 3s, 5s, 10s
294
- const fallbackDelays = [1000, 3000, 5000, 10000];
295
- for (const delay of fallbackDelays) {
296
- setTimeout(() => {
297
- if (this.status === "starting") {
298
- // Check if we got a preview URL via port registration
299
- const url = this.nodepod?.port(port);
300
- console.log(
301
- DBG,
302
- `boot: fallback check @${delay}ms, port(${port}) =`,
303
- url,
304
- "status =",
305
- this.status,
306
- );
307
- if (url) {
308
- this.previewUrl = this.rewritePreviewUrl(url);
309
- console.log(DBG, "fallback: previewUrl set to:", this.previewUrl);
310
- this.emitProgress("ready", "Application is running", 100);
311
- this.onServerReady?.(port, this.previewUrl);
312
- } else if (delay === fallbackDelays[fallbackDelays.length - 1]) {
313
- // Last check - report failure
314
- console.error(
315
- DBG,
316
- "boot: server never became ready after 10s. Process state:",
317
- {
318
- processExited: this.serverProcess?.exited,
319
- previewUrl: this.previewUrl,
320
- status: this.status,
321
- },
322
- );
323
- this.appendOutput(
324
- "[system] Server did not start within 10 seconds. Check the logs above for errors.",
325
- );
326
- }
327
- }
328
- }, delay);
329
- }
330
- } catch (err) {
331
- const message = err instanceof Error ? err.message : String(err);
332
- const stack = err instanceof Error ? err.stack : undefined;
333
- console.error(DBG, "boot: FATAL ERROR:", err);
334
- this.error = message;
335
- this.emitProgress("error", `Error: ${message}`, 0);
336
- this.emitError("boot", message, { stack });
337
- throw err;
338
- }
339
- }
340
-
341
- /**
342
- * Write a single file to the virtual filesystem.
343
- * Used for editor changes after boot.
344
- */
345
- async writeFile(path: string, content: string): Promise<void> {
346
- if (!this.nodepod) {
347
- throw new Error("Runtime not booted");
348
- }
349
-
350
- const fullPath = this.resolvePath(path);
351
- await this.nodepod.fs.writeFile(fullPath, content);
352
- this.currentFiles[path] = content;
353
- }
354
-
355
- /**
356
- * Read a file from the virtual filesystem.
357
- */
358
- async readFile(path: string): Promise<string> {
359
- if (!this.nodepod) {
360
- throw new Error("Runtime not booted");
361
- }
362
- const fullPath = this.resolvePath(path);
363
- return await this.nodepod.fs.readFile(fullPath, "utf-8");
364
- }
365
-
366
- /**
367
- * Restart the server process.
368
- * Kills the current process and re-runs the entry command.
369
- */
370
- async restart(): Promise<void> {
371
- if (this.serverProcess && !this.serverProcess.exited) {
372
- this.serverProcess.kill();
373
- // Wait for the process to exit
374
- await this.serverProcess.completion.catch(() => {});
375
- }
376
- this.previewUrl = null;
377
- this.emitProgress("starting", "Restarting application...", 75);
378
- await this.startServer();
379
- }
380
-
381
- /**
382
- * Tear down the runtime.
383
- * Kills all processes and releases resources.
384
- */
385
- teardown(): void {
386
- if (this.serverProcess && !this.serverProcess.exited) {
387
- this.serverProcess.kill();
388
- }
389
- this.nodepod?.teardown();
390
- this.nodepod = null;
391
- this.serverProcess = null;
392
- this.previewUrl = null;
393
- this.status = "initializing";
394
- this.terminalOutput = [];
395
- }
396
-
397
- /** Get raw Nodepod instance for advanced usage */
398
- getNodepod(): Nodepod | null {
399
- return this.nodepod;
400
- }
401
-
402
- // -------------------------------------------------------------------------
403
- // Private helpers
404
- // -------------------------------------------------------------------------
405
-
406
- /**
407
- * Rewrite a Nodepod virtual server URL to use /__preview__/ instead of /__virtual__/.
408
- *
409
- * WHY: Nodepod's serverUrl() returns /__virtual__/{port} URLs, but the
410
- * Service Worker only tracks iframe client IDs for /__preview__/{port}
411
- * navigations. Without client tracking, subresource requests (JS, CSS,
412
- * API calls) from the preview iframe are NOT intercepted by the SW —
413
- * they fall through to the real dev server and 404.
414
- */
415
- private rewritePreviewUrl(url: string): string {
416
- return url.replace("/__virtual__/", "/__preview__/");
417
- }
418
-
419
- /** Resolve a relative path against the workdir */
420
- private resolvePath(relativePath: string): string {
421
- const workdir = this.config.workdir ?? "/app";
422
- // Normalize: remove leading ./ and ensure proper path joining
423
- const clean = relativePath.replace(/^\.\//, "");
424
- if (clean.startsWith("/")) {
425
- return clean;
426
- }
427
- return `${workdir}/${clean}`;
428
- }
429
-
430
- /** Write all project files to the virtual filesystem */
431
- private async writeFiles(files: FileMap): Promise<void> {
432
- if (!this.nodepod) {
433
- return;
434
- }
435
-
436
- // Store originals for change tracking
437
- this.originalFiles = { ...files };
438
- this.currentFiles = { ...files };
439
-
440
- // Write files in parallel batches of 10
441
- const entries = Object.entries(files);
442
- const batchSize = 10;
443
-
444
- for (let i = 0; i < entries.length; i += batchSize) {
445
- const batch = entries.slice(i, i + batchSize);
446
- await Promise.all(
447
- batch.map(([path, content]) => {
448
- const fullPath = this.resolvePath(path);
449
- return this.nodepod!.fs.writeFile(fullPath, content);
450
- }),
451
- );
452
-
453
- // Update progress within the writing-files stage
454
- const percent = 20 + Math.round((i / entries.length) * 15);
455
- this.emitProgress(
456
- "writing-files",
457
- `Writing files... (${Math.min(i + batchSize, entries.length)}/${entries.length})`,
458
- percent,
459
- );
460
- }
461
- }
462
-
463
- /**
464
- * Install npm dependencies by spawning `npm install` in Nodepod.
465
- *
466
- * Nodepod's DependencyInstaller handles this internally via its
467
- * package registry. We spawn `node -e` with a require to trigger it,
468
- * or use the built-in npm install mechanism.
469
- */
470
- private async installDependencies(): Promise<void> {
471
- if (!this.nodepod) {
472
- return;
473
- }
474
-
475
- const workdir = this.config.workdir ?? "/app";
476
-
477
- try {
478
- // Parse package.json to find dependencies
479
- const pkgJsonContent = this.config.files["package.json"];
480
- if (!pkgJsonContent) {
481
- return;
482
- }
483
-
484
- const pkgJson = JSON.parse(pkgJsonContent);
485
- const deps = {
486
- ...(pkgJson.dependencies || {}),
487
- ...(pkgJson.devDependencies || {}),
488
- };
489
-
490
- const depNames = Object.keys(deps);
491
- if (depNames.length === 0) {
492
- return;
493
- }
494
-
495
- this.appendOutput(`Installing ${depNames.length} dependencies...`);
496
-
497
- // Use Nodepod's built-in package installer for each dependency
498
- for (let i = 0; i < depNames.length; i++) {
499
- const dep = depNames[i];
500
- const version = deps[dep];
501
- const specifier = version
502
- ? `${dep}@${version.replace(/[\^~>=<]/g, "")}`
503
- : dep;
504
-
505
- try {
506
- await this.nodepod.packages.install(specifier);
507
- this.appendOutput(` ✓ ${dep}`);
508
- } catch (err) {
509
- this.appendOutput(
510
- ` ✗ ${dep}: ${err instanceof Error ? err.message : err}`,
511
- );
512
- }
513
-
514
- // Update progress
515
- const percent = 40 + Math.round((i / depNames.length) * 30);
516
- this.emitProgress(
517
- "installing",
518
- `Installing ${dep}... (${i + 1}/${depNames.length})`,
519
- percent,
520
- );
521
- }
522
-
523
- this.appendOutput("Dependencies installed.");
524
- } catch (err) {
525
- this.appendOutput(`Warning: Could not parse package.json: ${err}`);
526
- }
527
- }
528
-
529
- /** Start the entry command (e.g., "node server.js") */
530
- private async startServer(): Promise<void> {
531
- if (!this.nodepod) {
532
- return;
533
- }
534
-
535
- const workdir = this.config.workdir ?? "/app";
536
- const command = this.config.entryCommand;
537
-
538
- // Parse the command: "node server.js" → cmd="node", args=["server.js"]
539
- const parts = command.split(/\s+/);
540
- const cmd = parts[0];
541
- const args = parts.slice(1);
542
-
543
- this.appendOutput(`\n$ ${command}`);
544
-
545
- this.serverProcess = await this.nodepod.spawn(cmd, args, { cwd: workdir });
546
-
547
- this.serverProcess.on("output", (chunk: string) => {
548
- this.appendOutput(chunk);
549
- });
550
-
551
- this.serverProcess.on("error", (chunk: string) => {
552
- this.appendOutput(`[stderr] ${chunk}`);
553
- // Emit structured error for stderr output.
554
- // Filter out noise: skip empty lines and common non-error warnings.
555
- const trimmed = chunk.trim();
556
- if (trimmed && !this.isStderrNoise(trimmed)) {
557
- this.emitError("process-stderr", trimmed);
558
- }
559
- });
560
-
561
- this.serverProcess.on("exit", (code: number) => {
562
- this.appendOutput(`\nProcess exited with code ${code}`);
563
- if (code !== 0 && this.status !== "ready") {
564
- this.error = `Process exited with code ${code}`;
565
- this.emitProgress("error", `Process exited with code ${code}`, 0);
566
- // Emit structured error for non-zero exit
567
- this.emitError("process-exit", `Process exited with code ${code}`, {
568
- // Include last 20 lines of terminal output for context
569
- stack: this.terminalOutput.slice(-20).join("\n"),
570
- });
571
- }
572
- });
573
- }
574
-
575
- /** Emit a progress update */
576
- private emitProgress(
577
- stage: BootStage,
578
- message: string,
579
- percent: number,
580
- ): void {
581
- this.status = stage;
582
- const progress: BootProgress = { stage, message, percent };
583
- this.onProgress?.(progress);
584
- }
585
-
586
- /** Append a line to terminal output */
587
- private appendOutput(line: string): void {
588
- this.terminalOutput.push(line);
589
- this.onOutput?.(line);
590
- }
591
-
592
- // -------------------------------------------------------------------------
593
- // Error helpers
594
- // -------------------------------------------------------------------------
595
-
596
- /** Generate a unique error ID */
597
- private nextErrorId(): string {
598
- return `err_${++this.errorIdCounter}_${Date.now()}`;
599
- }
600
-
601
- /**
602
- * Create and record a structured error from a process or boot event.
603
- *
604
- * Parses the error message to extract file path and line number when
605
- * possible, then enriches with surrounding source context.
606
- */
607
- private async emitError(
608
- category: SandboxErrorCategory,
609
- message: string,
610
- extra?: Partial<SandboxError>,
611
- ): Promise<void> {
612
- // Parse file/line from common Node.js error formats:
613
- // - "/app/server.js:42"
614
- // - "at Object.<anonymous> (/app/routes/api.js:15:8)"
615
- // - "SyntaxError: /app/server.js: Unexpected token (12:5)"
616
- let filePath = extra?.filePath;
617
- let line = extra?.line;
618
- let column = extra?.column;
619
-
620
- if (!filePath) {
621
- const parsed = this.parseErrorLocation(message + (extra?.stack ?? ""));
622
- filePath = parsed.filePath;
623
- line = parsed.line ?? line;
624
- column = parsed.column ?? column;
625
- }
626
-
627
- let sourceContext = extra?.sourceContext;
628
- if (filePath && line && !sourceContext) {
629
- sourceContext = await this.getSourceContext(filePath, line);
630
- }
631
-
632
- const error: SandboxError = {
633
- id: this.nextErrorId(),
634
- category,
635
- message,
636
- timestamp: new Date().toISOString(),
637
- ...extra,
638
- filePath,
639
- line,
640
- column,
641
- sourceContext,
642
- };
643
-
644
- this.errors.push(error);
645
- this.onSandboxError?.(error);
646
- }
647
-
648
- /**
649
- * Filter out common non-error stderr output.
650
- *
651
- * Many Node.js tools write informational messages to stderr
652
- * (deprecation warnings, experimental feature notices, etc.).
653
- * We don't want to flood the agent with these.
654
- */
655
- private isStderrNoise(text: string): boolean {
656
- const noisePatterns = [
657
- /^DeprecationWarning:/,
658
- /^ExperimentalWarning:/,
659
- /^\(node:\d+\) /,
660
- /^npm WARN /,
661
- /^warn /i,
662
- /^Debugger listening/,
663
- /^Debugger attached/,
664
- /^Waiting for the debugger/,
665
- ];
666
- return noisePatterns.some((p) => p.test(text));
667
- }
668
-
669
- /**
670
- * Parse file path and line/column from common error message formats.
671
- *
672
- * Handles:
673
- * - Node.js stack frames: "at func (/app/file.js:12:5)"
674
- * - Direct references: "/app/file.js:12:5"
675
- * - SyntaxError: "/app/file.js: Unexpected token (12:5)"
676
- */
677
- private parseErrorLocation(text: string): {
678
- filePath?: string;
679
- line?: number;
680
- column?: number;
681
- } {
682
- const workdir = this.config.workdir ?? "/app";
683
-
684
- // Pattern 1: "at ... (/app/path/file.js:LINE:COL)"
685
- const stackMatch = text.match(/\(?(\/[^:)]+):(\d+):(\d+)\)?/);
686
- if (stackMatch) {
687
- const absPath = stackMatch[1];
688
- const relativePath = absPath.startsWith(workdir + "/")
689
- ? absPath.slice(workdir.length + 1)
690
- : absPath;
691
- return {
692
- filePath: relativePath,
693
- line: parseInt(stackMatch[2], 10),
694
- column: parseInt(stackMatch[3], 10),
695
- };
696
- }
697
-
698
- // Pattern 2: "file.js:LINE" (no column)
699
- const simpleMatch = text.match(/\(?(\/[^:)]+):(\d+)\)?/);
700
- if (simpleMatch) {
701
- const absPath = simpleMatch[1];
702
- const relativePath = absPath.startsWith(workdir + "/")
703
- ? absPath.slice(workdir.length + 1)
704
- : absPath;
705
- return {
706
- filePath: relativePath,
707
- line: parseInt(simpleMatch[2], 10),
708
- };
709
- }
710
-
711
- // Pattern 3: "Unexpected token (LINE:COL)" with a file path earlier
712
- const syntaxMatch = text.match(/(\/[^\s:]+)\s*:\s*[^(]*\((\d+):(\d+)\)/);
713
- if (syntaxMatch) {
714
- const absPath = syntaxMatch[1];
715
- const relativePath = absPath.startsWith(workdir + "/")
716
- ? absPath.slice(workdir.length + 1)
717
- : absPath;
718
- return {
719
- filePath: relativePath,
720
- line: parseInt(syntaxMatch[2], 10),
721
- column: parseInt(syntaxMatch[3], 10),
722
- };
723
- }
724
-
725
- return {};
726
- }
727
-
728
- /**
729
- * Get surrounding source code lines from the virtual filesystem.
730
- *
731
- * Returns ~10 lines centered on the target line, formatted as
732
- * "lineNum: content" for each line. This gives the AI agent enough
733
- * context to construct a surgical fix.
734
- */
735
- private async getSourceContext(
736
- filePath: string,
737
- targetLine: number,
738
- windowSize = 5,
739
- ): Promise<string | undefined> {
740
- try {
741
- const content = this.currentFiles[filePath];
742
- if (!content) {
743
- // Try reading from the FS directly (file might have been written
744
- // outside of our tracking, e.g., by npm install)
745
- if (!this.nodepod) return undefined;
746
- const fullPath = this.resolvePath(filePath);
747
- const exists = await this.nodepod.fs.exists(fullPath);
748
- if (!exists) return undefined;
749
- const fsContent = await this.nodepod.fs.readFile(fullPath, "utf-8");
750
- return this.formatSourceContext(fsContent, targetLine, windowSize);
751
- }
752
- return this.formatSourceContext(content, targetLine, windowSize);
753
- } catch {
754
- return undefined;
755
- }
756
- }
757
-
758
- /**
759
- * Format source code lines centered on a target line.
760
- * Output: "lineNum: content\n" for each line in the window.
761
- */
762
- private formatSourceContext(
763
- content: string,
764
- targetLine: number,
765
- windowSize: number,
766
- ): string {
767
- const lines = content.split("\n");
768
- const start = Math.max(0, targetLine - windowSize - 1);
769
- const end = Math.min(lines.length, targetLine + windowSize);
770
- return lines
771
- .slice(start, end)
772
- .map((l, i) => `${start + i + 1}: ${l}`)
773
- .join("\n");
774
- }
775
- }