esque-bridge 0.2.2 → 0.3.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.
Files changed (2) hide show
  1. package/index.js +119 -2
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -373,6 +373,122 @@ function requireAuth(req, res, next) {
373
373
  next();
374
374
  }
375
375
 
376
+ // ── Live preview (bridge-owned) ───────────────────────────────────────
377
+ // The agent declares a preview with a marker line in its reply:
378
+ // ESQUE_PREVIEW: <dev server start command> @ <port>
379
+ // e.g. ESQUE_PREVIEW: npm run dev @ 3000
380
+ //
381
+ // The BRIDGE runs that dev server and tunnels it — NOT the one-shot
382
+ // `claude --print`, which exits the instant its turn ends and would
383
+ // orphan/kill anything it started (that's why the old "agent starts its
384
+ // own tunnel" approach left the preview URL dead by the time you tapped
385
+ // it). cloudflared is preferred over localtunnel because its URL has no
386
+ // "click to continue" interstitial that would block the in-app WebView.
387
+ const net = require('net');
388
+ const { execSync } = require('child_process');
389
+
390
+ let preview = null; // { port, proc, kind, url, tunnel, tunnelProc }
391
+
392
+ function killPreview() {
393
+ if (!preview) return;
394
+ try { preview.proc && preview.proc.kill('SIGTERM'); } catch {}
395
+ try { preview.tunnel && preview.tunnel.close(); } catch {}
396
+ try { preview.tunnelProc && preview.tunnelProc.kill('SIGTERM'); } catch {}
397
+ preview = null;
398
+ }
399
+
400
+ function waitForPort(port, timeoutMs) {
401
+ const start = Date.now();
402
+ return new Promise((resolve) => {
403
+ const tick = () => {
404
+ const s = net.connect(port, '127.0.0.1');
405
+ s.on('connect', () => { s.destroy(); resolve(true); });
406
+ s.on('error', () => {
407
+ s.destroy();
408
+ if (Date.now() - start > timeoutMs) resolve(false);
409
+ else setTimeout(tick, 600);
410
+ });
411
+ };
412
+ tick();
413
+ });
414
+ }
415
+
416
+ function hasCloudflared() {
417
+ try { execSync('which cloudflared', { stdio: 'ignore' }); return true; }
418
+ catch { return false; }
419
+ }
420
+
421
+ // Open a public tunnel to a local port. Prefers cloudflared (clean URL,
422
+ // no interstitial), falls back to localtunnel. Resolves the tunnel handle.
423
+ function openPreviewTunnel(port) {
424
+ return new Promise(async (resolve) => {
425
+ if (hasCloudflared()) {
426
+ const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
427
+ stdio: ['ignore', 'pipe', 'pipe'],
428
+ });
429
+ let done = false;
430
+ const scan = (d) => {
431
+ const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
432
+ if (m && !done) { done = true; resolve({ url: m[0], kind: 'cloudflared', tunnelProc: proc }); }
433
+ };
434
+ proc.stdout.on('data', scan);
435
+ proc.stderr.on('data', scan);
436
+ setTimeout(() => { if (!done) { done = true; try { proc.kill(); } catch {} resolve(null); } }, 25000);
437
+ } else {
438
+ try {
439
+ const tunnel = await localtunnel({ port });
440
+ resolve({ url: tunnel.url, kind: 'localtunnel', tunnel });
441
+ } catch { resolve(null); }
442
+ }
443
+ });
444
+ }
445
+
446
+ async function startPreview(cmd, port) {
447
+ killPreview();
448
+ console.log(`[preview] starting: ${cmd} (port ${port})`);
449
+ const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
450
+ proc.stdout.on('data', (d) => process.stdout.write(`[preview] ${d}`));
451
+ proc.stderr.on('data', (d) => process.stdout.write(`[preview] ${d}`));
452
+ proc.on('exit', (code) => console.log(`[preview] dev server exited (${code})`));
453
+ preview = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null };
454
+
455
+ const up = await waitForPort(port, 60000);
456
+ if (!up) { console.log('[preview] dev server never opened the port'); return null; }
457
+
458
+ const t = await openPreviewTunnel(port);
459
+ if (!t) { console.log('[preview] could not open a public tunnel'); return null; }
460
+ preview.url = t.url;
461
+ preview.kind = t.kind;
462
+ preview.tunnel = t.tunnel || null;
463
+ preview.tunnelProc = t.tunnelProc || null;
464
+ console.log(`[preview] live at ${t.url} (${t.kind})`);
465
+ return t.url;
466
+ }
467
+
468
+ const PREVIEW_RE = /^[ \t]*ESQUE_PREVIEW:\s*(.+?)\s*@\s*(\d+)[ \t]*$/im;
469
+
470
+ // If the agent's reply declares a preview, start it (bridge-owned) and
471
+ // swap the marker line for the public URL the phone can open.
472
+ async function applyPreview(text) {
473
+ if (!text) return text;
474
+ const m = text.match(PREVIEW_RE);
475
+ if (!m) return text;
476
+ const cmd = m[1].trim();
477
+ const port = Number(m[2]);
478
+ let url = null;
479
+ try { url = await startPreview(cmd, port); } catch (e) { console.error('[preview] error:', e.message); }
480
+ const replacement = url
481
+ ? `🔗 Live preview: ${url}`
482
+ : `(Couldn't auto-start the preview — run \`${cmd}\` in ${WORKDIR} yourself.)`;
483
+ return text.replace(PREVIEW_RE, replacement);
484
+ }
485
+
486
+ async function runAgentWithPreview(prompt, sessionId) {
487
+ const result = await runAgent(prompt, sessionId);
488
+ if (result && result.text) result.text = await applyPreview(result.text);
489
+ return result;
490
+ }
491
+
376
492
  // ── Async job store ───────────────────────────────────────────────────
377
493
  // A prompt can run for minutes (e.g. a full project scaffold). Holding one
378
494
  // HTTP connection open that long is fragile over tunnels and times the
@@ -417,7 +533,7 @@ async function executeHandler(req, res) {
417
533
  const jobId = newJobId();
418
534
  jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
419
535
  res.json({ jobId, status: 'working' });
420
- runAgent(prompt, esqueSessionId)
536
+ runAgentWithPreview(prompt, esqueSessionId)
421
537
  .then((result) => {
422
538
  jobs.set(jobId, {
423
539
  status: result.isError ? 'blocked' : 'finished',
@@ -463,7 +579,7 @@ async function executeHandler(req, res) {
463
579
  };
464
580
 
465
581
  try {
466
- const result = await runAgent(prompt, esqueSessionId);
582
+ const result = await runAgentWithPreview(prompt, esqueSessionId);
467
583
  finish({
468
584
  text: result.text,
469
585
  status: result.isError ? 'blocked' : 'finished',
@@ -537,6 +653,7 @@ async function main() {
537
653
 
538
654
  const shutdown = (signal) => {
539
655
  console.log(`\n Received ${signal} — closing tunnel…`);
656
+ killPreview();
540
657
  try {
541
658
  tunnel.close();
542
659
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, 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"