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.
- package/index.js +119 -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
|
-
|
|
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
|
|
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.
|
|
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"
|