esque-bridge 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +148 -13
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -584,14 +584,103 @@ function openPreviewTunnel(port) {
584
584
  });
585
585
  }
586
586
 
587
+ // Strip ANSI color codes bundlers embed in their error output.
588
+ function stripAnsi(s) {
589
+ return String(s).replace(/\[[0-9;]*m/g, '');
590
+ }
591
+
592
+ // Reduce a bundler error (Metro's JSON payload, or raw stderr text) to a
593
+ // short, human-readable summary: the first few meaningful lines, no stack
594
+ // trace, no code frame, no ANSI, project paths shortened. This is what the
595
+ // phone shows in place of a white screen, so keep it tight.
596
+ function summarizeBuildError(status, body) {
597
+ let msg = '';
598
+ try {
599
+ const j = JSON.parse(body);
600
+ msg = j.message || (j.errors && j.errors[0] && j.errors[0].description) || '';
601
+ } catch {
602
+ msg = body;
603
+ }
604
+ msg = stripAnsi(msg).trim();
605
+ const head = [];
606
+ for (const l of msg.split('\n').map((x) => x.trimEnd()).filter(Boolean)) {
607
+ if (/^\s*at\s/.test(l)) break; // stack trace
608
+ if (/^\s*>?\s*\d+\s*\|/.test(l)) break; // code frame ("> 1 |")
609
+ head.push(l);
610
+ if (head.length >= 5) break;
611
+ }
612
+ let out = head.join('\n').split(`${WORKDIR}/`).join('').trim();
613
+ if (out.length > 600) out = out.slice(0, 600) + '…';
614
+ return out || `Build failed (HTTP ${status}).`;
615
+ }
616
+
617
+ // Fallback for compile errors an HTTP probe can't see (e.g. Vite/Next inject
618
+ // an in-page overlay but still log the failure to stderr): scan the captured
619
+ // dev-server output for a known failure signature.
620
+ function scanOutputForError(out) {
621
+ if (!out) return null;
622
+ const lines = stripAnsi(out).split('\n');
623
+ const sig =
624
+ /(unable to resolve|cannot find module|module not found|failed to compile|could not resolve|\bERROR in\b|SyntaxError|Transform failed|\[ERROR\])/i;
625
+ for (let i = 0; i < lines.length; i++) {
626
+ if (sig.test(lines[i])) {
627
+ let s = lines.slice(i, i + 4).map((l) => l.trim()).filter(Boolean).join('\n');
628
+ if (s.length > 600) s = s.slice(0, 600) + '…';
629
+ return s.split(`${WORKDIR}/`).join('');
630
+ }
631
+ }
632
+ return null;
633
+ }
634
+
635
+ // Fetch a path on the local dev server with a hard timeout.
636
+ async function fetchLocal(port, path, ms) {
637
+ const ctrl = new AbortController();
638
+ const timer = setTimeout(() => ctrl.abort(), ms);
639
+ try {
640
+ const r = await fetch(`http://127.0.0.1:${port}${path}`, { signal: ctrl.signal });
641
+ return { status: r.status, body: await r.text() };
642
+ } catch {
643
+ return null;
644
+ } finally {
645
+ clearTimeout(timer);
646
+ }
647
+ }
648
+
649
+ // Probe a freshly-started dev server for a build failure that would otherwise
650
+ // render as a blank screen on the phone. Metro builds the web bundle lazily,
651
+ // so we trigger it ourselves and catch fast failures (missing deps, syntax
652
+ // errors return in <2s) with a short timeout — we deliberately DON'T wait out
653
+ // a slow-but-healthy compile. Returns a short error string, or null if it
654
+ // looks fine / is still compiling.
655
+ async function probeBuildError(port) {
656
+ const root = await fetchLocal(port, '/', 5000);
657
+ if (!root) return null; // couldn't reach it — don't cry wolf
658
+ if (root.status >= 500) return summarizeBuildError(root.status, root.body);
659
+
660
+ // Expo/Metro: the HTML points at a *.bundle script. Fetching it forces the
661
+ // compile and surfaces the react-native-web class of error as a 500.
662
+ const m = root.body.match(/<script[^>]+src="([^"]*\.bundle[^"]*)"/i);
663
+ if (m) {
664
+ const b = await fetchLocal(port, m[1].replace(/&amp;/g, '&'), 5000);
665
+ if (b && b.status >= 500) return summarizeBuildError(b.status, b.body);
666
+ }
667
+
668
+ return scanOutputForError(preview && preview.output);
669
+ }
670
+
587
671
  async function startPreview(cmd, port) {
588
672
  killPreview();
589
673
  console.log(`[preview] starting: ${cmd} (port ${port})`);
590
674
  const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
591
- proc.stdout.on('data', (d) => process.stdout.write(`[preview] ${d}`));
592
- proc.stderr.on('data', (d) => process.stdout.write(`[preview] ${d}`));
675
+ preview = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null, output: '', buildError: null };
676
+ // Keep a rolling tail of output so the error scanner has something to read.
677
+ const capture = (d) => {
678
+ process.stdout.write(`[preview] ${d}`);
679
+ if (preview) preview.output = (preview.output + d).slice(-16000);
680
+ };
681
+ proc.stdout.on('data', capture);
682
+ proc.stderr.on('data', capture);
593
683
  proc.on('exit', (code) => console.log(`[preview] dev server exited (${code})`));
594
- preview = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null };
595
684
 
596
685
  const up = await waitForPort(port, 60000);
597
686
  if (!up) { console.log('[preview] dev server never opened the port'); return null; }
@@ -603,7 +692,14 @@ async function startPreview(cmd, port) {
603
692
  preview.tunnel = t.tunnel || null;
604
693
  preview.tunnelProc = t.tunnelProc || null;
605
694
  console.log(`[preview] live at ${t.url} (${t.kind})`);
606
- return t.url;
695
+
696
+ // Before handing the URL to the phone, make sure the page will actually
697
+ // render — a dev server can be "up" while its bundle fails to compile.
698
+ const buildError = await probeBuildError(port);
699
+ if (preview) preview.buildError = buildError;
700
+ if (buildError) console.error(`[preview] ⚠ web build is failing:\n${buildError}`);
701
+
702
+ return { url: t.url, buildError };
607
703
  }
608
704
 
609
705
  // Tolerate leading markdown punctuation / code-fence backticks the agent
@@ -613,23 +709,62 @@ const PREVIEW_RE = /^[ \t>*`-]*ESQUE_PREVIEW:\s*(.+?)\s*@\s*(\d+)\s*`?[ \t]*$/im
613
709
 
614
710
  // If the agent's reply declares a preview, start it (bridge-owned) and
615
711
  // swap the marker line for the public URL the phone can open.
712
+ // Returns { text, buildError }. `buildError` is the short failure summary when
713
+ // the declared preview started but its web bundle didn't compile (else null) —
714
+ // the caller uses it to drive the auto-fix loop.
616
715
  async function applyPreview(text) {
617
- if (!text) return text;
716
+ if (!text) return { text, buildError: null };
618
717
  const m = text.match(PREVIEW_RE);
619
- if (!m) return text;
718
+ if (!m) return { text, buildError: null };
620
719
  const cmd = m[1].trim();
621
720
  const port = Number(m[2]);
622
- let url = null;
623
- try { url = await startPreview(cmd, port); } catch (e) { console.error('[preview] error:', e.message); }
624
- const replacement = url
625
- ? `🔗 Live preview: ${url}`
626
- : `(Couldn't auto-start the preview — run \`${cmd}\` in ${WORKDIR} yourself.)`;
627
- return text.replace(PREVIEW_RE, replacement);
721
+ let result = null;
722
+ try { result = await startPreview(cmd, port); } catch (e) { console.error('[preview] error:', e.message); }
723
+ let replacement;
724
+ let buildError = null;
725
+ if (!result) {
726
+ replacement = `(Couldn't auto-start the preview — run \`${cmd}\` in ${WORKDIR} yourself.)`;
727
+ } else if (result.buildError) {
728
+ buildError = result.buildError;
729
+ // Surface the failure as plain text WITHOUT the URL — the app turns any
730
+ // URL in a message into a "Preview Build" pill, and we don't want a pill
731
+ // that just opens a blank page.
732
+ replacement =
733
+ `⚠️ Preview build failed — the web bundle didn't compile:\n\n` +
734
+ `${result.buildError}\n\n` +
735
+ `Fix that and I'll preview again. (Dev server is still running locally on port ${port}.)`;
736
+ } else {
737
+ replacement = `🔗 Live preview: ${result.url}`;
738
+ }
739
+ return { text: text.replace(PREVIEW_RE, replacement), buildError };
628
740
  }
629
741
 
630
742
  async function runAgentWithPreview(prompt, sessionId) {
631
743
  const result = await runAgent(prompt, sessionId);
632
- if (result && result.text) result.text = await applyPreview(result.text);
744
+ if (!result || !result.text) return result;
745
+
746
+ const applied = await applyPreview(result.text);
747
+ result.text = applied.text;
748
+
749
+ // Auto-fix (one retry): the agent's own session history never saw our
750
+ // rewritten reply, so if the preview failed to build we hand the error back
751
+ // to the agent — same session (--resume), so it has full context — and let
752
+ // it fix the cause and re-emit the marker. Bounded at a single retry so a
753
+ // stubborn error can't loop.
754
+ if (applied.buildError) {
755
+ console.log('[preview] build failed — handing the error back to the agent (1 retry)');
756
+ const fixPrompt =
757
+ `The live preview you just started failed to build with this error:\n\n` +
758
+ `${applied.buildError}\n\n` +
759
+ `Fix the root cause in the code, then re-emit the ESQUE_PREVIEW marker on ` +
760
+ `its own line. Keep the explanation brief — make the fix and output the marker.`;
761
+ let fix = null;
762
+ try { fix = await runAgent(fixPrompt, sessionId); } catch (e) { console.error('[preview] auto-fix run failed:', e.message); }
763
+ if (fix && fix.text) {
764
+ const fixApplied = await applyPreview(fix.text);
765
+ result.text = `${applied.text}\n\n— Auto-fix attempt —\n${fixApplied.text}`;
766
+ }
767
+ }
633
768
  return result;
634
769
  }
635
770
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Codex, Aider, or any custom command) via a tunnel + QR code, so prompts run through your subscription instead of per-token API billing.",
5
5
  "bin": {
6
6
  "esque-bridge": "index.js"