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.
- package/index.js +148 -13
- 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(/&/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
|
|
592
|
-
|
|
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
|
-
|
|
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
|
|
623
|
-
try {
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
|
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.
|
|
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"
|