esque-bridge 0.2.1 → 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 +192 -10
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -71,7 +71,10 @@ const PORT = Number(argv.port || process.env.PORT || 3030);
|
|
|
71
71
|
const WORKDIR = path.resolve(
|
|
72
72
|
argv.workdir || process.env.CLAUDE_WORKDIR || process.cwd(),
|
|
73
73
|
);
|
|
74
|
-
|
|
74
|
+
// 20 min default. With the async protocol the phone no longer holds a
|
|
75
|
+
// connection open, so a generous ceiling just lets big agentic tasks (full
|
|
76
|
+
// scaffolds) finish. Override with --timeout.
|
|
77
|
+
const TIMEOUT_MS = Number(argv.timeout || 20 * 60 * 1000);
|
|
75
78
|
const AGENT_TYPE = String(argv.agent || process.env.ESQUE_AGENT || 'claude').toLowerCase();
|
|
76
79
|
const CUSTOM_CMD = argv.cmd || process.env.ESQUE_CMD || null;
|
|
77
80
|
const BIN_OVERRIDE = argv.bin || null;
|
|
@@ -370,6 +373,142 @@ function requireAuth(req, res, next) {
|
|
|
370
373
|
next();
|
|
371
374
|
}
|
|
372
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
|
+
|
|
492
|
+
// ── Async job store ───────────────────────────────────────────────────
|
|
493
|
+
// A prompt can run for minutes (e.g. a full project scaffold). Holding one
|
|
494
|
+
// HTTP connection open that long is fragile over tunnels and times the
|
|
495
|
+
// phone out. Newer phones advertise `x-esque-async`; they get a jobId back
|
|
496
|
+
// immediately and poll GET /result/:id while the agent runs in the
|
|
497
|
+
// background. Jobs are evicted after a TTL so the map can't grow unbounded.
|
|
498
|
+
const jobs = new Map(); // jobId -> { status, text, createdAt }
|
|
499
|
+
const RESULT_TTL_MS = 30 * 60 * 1000;
|
|
500
|
+
let jobSeq = 0;
|
|
501
|
+
function newJobId() {
|
|
502
|
+
jobSeq += 1;
|
|
503
|
+
return `j${Date.now().toString(36)}${jobSeq.toString(36)}`;
|
|
504
|
+
}
|
|
505
|
+
function gcJobs() {
|
|
506
|
+
const now = Date.now();
|
|
507
|
+
for (const [id, j] of jobs) {
|
|
508
|
+
if (now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
373
512
|
async function executeHandler(req, res) {
|
|
374
513
|
const body = req.body || {};
|
|
375
514
|
const prompt = String(body.prompt || '');
|
|
@@ -384,12 +523,42 @@ async function executeHandler(req, res) {
|
|
|
384
523
|
`[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} ${AGENT_TYPE}=${prev ?? 'new'}`,
|
|
385
524
|
);
|
|
386
525
|
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
526
|
+
// ── Async path (polling-capable phones) ─────────────────────────────
|
|
527
|
+
// Return a jobId instantly and run the agent in the background. The phone
|
|
528
|
+
// polls GET /result/:id every few seconds — tiny requests that survive
|
|
529
|
+
// any tunnel and never time out, no matter how long the task runs.
|
|
530
|
+
const wantsAsync = req.header('x-esque-async') === '1' || body.async === true;
|
|
531
|
+
if (wantsAsync) {
|
|
532
|
+
gcJobs();
|
|
533
|
+
const jobId = newJobId();
|
|
534
|
+
jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
|
|
535
|
+
res.json({ jobId, status: 'working' });
|
|
536
|
+
runAgentWithPreview(prompt, esqueSessionId)
|
|
537
|
+
.then((result) => {
|
|
538
|
+
jobs.set(jobId, {
|
|
539
|
+
status: result.isError ? 'blocked' : 'finished',
|
|
540
|
+
text: result.text,
|
|
541
|
+
createdAt: Date.now(),
|
|
542
|
+
});
|
|
543
|
+
console.log(
|
|
544
|
+
`[bridge] done job=${jobId} ${result.isError ? 'blocked' : 'finished'}`,
|
|
545
|
+
);
|
|
546
|
+
})
|
|
547
|
+
.catch((err) => {
|
|
548
|
+
jobs.set(jobId, {
|
|
549
|
+
status: 'blocked',
|
|
550
|
+
text: `${adapter.label} failed: ${err.message}`,
|
|
551
|
+
createdAt: Date.now(),
|
|
552
|
+
});
|
|
553
|
+
console.error(`[bridge] error job=${jobId}:`, err.message);
|
|
554
|
+
});
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ── Legacy synchronous path (older phones) ──────────────────────────
|
|
559
|
+
// Hold the connection open with a keep-alive heartbeat so idle-closing
|
|
560
|
+
// proxies don't drop it while the agent thinks. The phone trims the body
|
|
561
|
+
// before JSON.parse, so the leading whitespace is harmless.
|
|
393
562
|
res.status(200);
|
|
394
563
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
395
564
|
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
@@ -410,21 +579,33 @@ async function executeHandler(req, res) {
|
|
|
410
579
|
};
|
|
411
580
|
|
|
412
581
|
try {
|
|
413
|
-
const result = await
|
|
582
|
+
const result = await runAgentWithPreview(prompt, esqueSessionId);
|
|
414
583
|
finish({
|
|
415
584
|
text: result.text,
|
|
416
585
|
status: result.isError ? 'blocked' : 'finished',
|
|
417
586
|
});
|
|
418
587
|
} catch (err) {
|
|
419
588
|
console.error('[bridge] error:', err.message);
|
|
420
|
-
// Headers are already sent (heartbeat), so the error rides in the body
|
|
421
|
-
// with status 'blocked' rather than an HTTP 500.
|
|
422
589
|
finish({ text: `${adapter.label} failed: ${err.message}`, status: 'blocked' });
|
|
423
590
|
}
|
|
424
591
|
}
|
|
425
592
|
|
|
593
|
+
// Poll endpoint for the async protocol. Returns the job's current state:
|
|
594
|
+
// { status: 'working' | 'finished' | 'blocked', text }.
|
|
595
|
+
function resultHandler(req, res) {
|
|
596
|
+
const job = jobs.get(req.params.id);
|
|
597
|
+
if (!job) {
|
|
598
|
+
return res.status(404).json({
|
|
599
|
+
status: 'blocked',
|
|
600
|
+
text: 'That task is no longer available — the bridge may have restarted.',
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
res.json({ status: job.status, text: job.text });
|
|
604
|
+
}
|
|
605
|
+
|
|
426
606
|
app.post('/execute', requireAuth, executeHandler);
|
|
427
607
|
app.post('/', requireAuth, executeHandler);
|
|
608
|
+
app.get('/result/:id', requireAuth, resultHandler);
|
|
428
609
|
app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
|
|
429
610
|
|
|
430
611
|
// --- Boot -----------------------------------------------------------------
|
|
@@ -472,6 +653,7 @@ async function main() {
|
|
|
472
653
|
|
|
473
654
|
const shutdown = (signal) => {
|
|
474
655
|
console.log(`\n Received ${signal} — closing tunnel…`);
|
|
656
|
+
killPreview();
|
|
475
657
|
try {
|
|
476
658
|
tunnel.close();
|
|
477
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"
|