esque-bridge 0.6.7 → 0.6.9
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 +293 -59
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -25,6 +25,7 @@ const { spawn } = require('child_process');
|
|
|
25
25
|
const crypto = require('crypto');
|
|
26
26
|
const fs = require('fs');
|
|
27
27
|
const path = require('path');
|
|
28
|
+
const BRIDGE_VERSION = require('./package.json').version;
|
|
28
29
|
const os = require('os');
|
|
29
30
|
|
|
30
31
|
// Windows has no POSIX process groups or `.cmd`-aware spawn, so several
|
|
@@ -153,14 +154,31 @@ function saveSessions() {
|
|
|
153
154
|
console.warn('[bridge] could not persist session map:', err.message);
|
|
154
155
|
}
|
|
155
156
|
}
|
|
157
|
+
// Keyed by workdir AS WELL as the esque session id: the sessions file is
|
|
158
|
+
// global (~/.esque-bridge-sessions.json) but Claude Code stores its sessions
|
|
159
|
+
// per project directory — a cliSessionId recorded in project A can never be
|
|
160
|
+
// resumed by a bridge running in project B. Without the workdir in the key,
|
|
161
|
+
// re-pointing a project at a different folder reuses the foreign id and every
|
|
162
|
+
// prompt fails. (Old-format flat entries simply never match → clean restart.)
|
|
163
|
+
function sessionKey(esqueSessionId) {
|
|
164
|
+
return `${WORKDIR}::${esqueSessionId}`;
|
|
165
|
+
}
|
|
156
166
|
function getCliSessionId(agent, esqueSessionId) {
|
|
157
167
|
if (!esqueSessionId) return null;
|
|
158
|
-
return sessionMap[agent]?.[esqueSessionId] ?? null;
|
|
168
|
+
return sessionMap[agent]?.[sessionKey(esqueSessionId)] ?? null;
|
|
159
169
|
}
|
|
160
170
|
function setCliSessionId(agent, esqueSessionId, cliId) {
|
|
161
171
|
if (!cliId || !esqueSessionId) return;
|
|
162
172
|
if (!sessionMap[agent]) sessionMap[agent] = {};
|
|
163
|
-
sessionMap[agent][esqueSessionId] = cliId;
|
|
173
|
+
sessionMap[agent][sessionKey(esqueSessionId)] = cliId;
|
|
174
|
+
saveSessions();
|
|
175
|
+
}
|
|
176
|
+
// Drop a mapping that turned out to be dead (the CLI no longer recognizes the
|
|
177
|
+
// session id) so the next attempt starts a fresh CLI session instead of
|
|
178
|
+
// failing on --resume forever.
|
|
179
|
+
function clearCliSessionId(agent, esqueSessionId) {
|
|
180
|
+
if (!esqueSessionId || !sessionMap[agent]) return;
|
|
181
|
+
delete sessionMap[agent][sessionKey(esqueSessionId)];
|
|
164
182
|
saveSessions();
|
|
165
183
|
}
|
|
166
184
|
|
|
@@ -247,9 +265,15 @@ const ADAPTERS = {
|
|
|
247
265
|
// Aider keeps its own .aider.chat.history.md per-directory, so we
|
|
248
266
|
// don't need to track a session id — every invocation against the
|
|
249
267
|
// same workdir picks up where the last one left off.
|
|
250
|
-
|
|
268
|
+
//
|
|
269
|
+
// The prompt rides as the --message VALUE (argv, no shell → no injection,
|
|
270
|
+
// and ARG_MAX comfortably fits blueprint-sized prompts). Aider has no
|
|
271
|
+
// "--message - = read stdin" convention; passing '-' would send the
|
|
272
|
+
// agent a literal one-character message.
|
|
273
|
+
promptInArgs: true,
|
|
274
|
+
buildArgs(prompt) {
|
|
251
275
|
return [
|
|
252
|
-
'--message',
|
|
276
|
+
'--message', prompt,
|
|
253
277
|
'--no-stream',
|
|
254
278
|
'--yes-always', // skip the "apply edit? y/n" prompts
|
|
255
279
|
'--no-pretty', // ANSI-free output for parsing
|
|
@@ -313,6 +337,12 @@ if (AGENT_TYPE === 'custom') {
|
|
|
313
337
|
|
|
314
338
|
// --- Runner ---------------------------------------------------------------
|
|
315
339
|
|
|
340
|
+
// Kill-handle for whatever agent child is currently running, so shutdown can
|
|
341
|
+
// stop it. The agent is spawned detached into its own process group, which
|
|
342
|
+
// means a terminal Ctrl-C does NOT reach it — without this, the agent keeps
|
|
343
|
+
// editing files after the bridge is gone.
|
|
344
|
+
let activeAgentKill = null;
|
|
345
|
+
|
|
316
346
|
function runAgent(prompt, esqueSessionId) {
|
|
317
347
|
return new Promise((resolve, reject) => {
|
|
318
348
|
const prevId = getCliSessionId(AGENT_TYPE, esqueSessionId);
|
|
@@ -325,10 +355,10 @@ function runAgent(prompt, esqueSessionId) {
|
|
|
325
355
|
}
|
|
326
356
|
|
|
327
357
|
// For 'custom', substitute the {prompt} placeholder in argv. For
|
|
328
|
-
// built-in adapters, the prompt
|
|
329
|
-
//
|
|
358
|
+
// built-in adapters, the prompt rides via stdin (avoids ARG_MAX on long
|
|
359
|
+
// blueprint payloads) unless the adapter opts into argv delivery.
|
|
330
360
|
let bin = AGENT_BIN;
|
|
331
|
-
let usesStdin =
|
|
361
|
+
let usesStdin = !adapter.promptInArgs;
|
|
332
362
|
if (AGENT_TYPE === 'custom') {
|
|
333
363
|
bin = argv.shift();
|
|
334
364
|
argv = argv.map((a) => a.replace('{prompt}', prompt));
|
|
@@ -359,6 +389,7 @@ function runAgent(prompt, esqueSessionId) {
|
|
|
359
389
|
const settle = (fn) => (val) => {
|
|
360
390
|
if (settled) return;
|
|
361
391
|
settled = true;
|
|
392
|
+
if (activeAgentKill === killTree) activeAgentKill = null;
|
|
362
393
|
fn(val);
|
|
363
394
|
};
|
|
364
395
|
const resolveOnce = settle(resolve);
|
|
@@ -392,6 +423,8 @@ function runAgent(prompt, esqueSessionId) {
|
|
|
392
423
|
}
|
|
393
424
|
};
|
|
394
425
|
|
|
426
|
+
activeAgentKill = killTree;
|
|
427
|
+
|
|
395
428
|
const killTimer = setTimeout(() => {
|
|
396
429
|
killTree('SIGTERM');
|
|
397
430
|
// Escalate to SIGKILL if the agent ignores SIGTERM.
|
|
@@ -466,6 +499,26 @@ function runAgent(prompt, esqueSessionId) {
|
|
|
466
499
|
});
|
|
467
500
|
}
|
|
468
501
|
|
|
502
|
+
// `--resume <id>` fails hard when the CLI no longer recognizes the stored
|
|
503
|
+
// session (its own history GC'd it, or the project moved). Without recovery
|
|
504
|
+
// the SAME dead id is retried on every subsequent prompt — the session is
|
|
505
|
+
// bricked forever. Detect that specific failure, drop the mapping, and rerun
|
|
506
|
+
// once as a fresh CLI session.
|
|
507
|
+
const RESUME_FAIL_RE = /no conversation found|session.*not found|unknown session|invalid session/i;
|
|
508
|
+
async function runAgentResilient(prompt, esqueSessionId) {
|
|
509
|
+
const prevId = getCliSessionId(AGENT_TYPE, esqueSessionId);
|
|
510
|
+
try {
|
|
511
|
+
return await runAgent(prompt, esqueSessionId);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
if (prevId && RESUME_FAIL_RE.test(String(err && err.message))) {
|
|
514
|
+
console.warn('[bridge] stored CLI session is gone — retrying as a fresh session');
|
|
515
|
+
clearCliSessionId(AGENT_TYPE, esqueSessionId);
|
|
516
|
+
return runAgent(prompt, esqueSessionId);
|
|
517
|
+
}
|
|
518
|
+
throw err;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
469
522
|
// --- HTTP server ----------------------------------------------------------
|
|
470
523
|
|
|
471
524
|
const app = express();
|
|
@@ -491,7 +544,16 @@ function workdirInfo() {
|
|
|
491
544
|
} catch {
|
|
492
545
|
/* unreadable dir — report unknown via -1 */
|
|
493
546
|
}
|
|
494
|
-
return {
|
|
547
|
+
return {
|
|
548
|
+
workdir: WORKDIR,
|
|
549
|
+
workdirName: path.basename(WORKDIR),
|
|
550
|
+
empty,
|
|
551
|
+
git,
|
|
552
|
+
entries,
|
|
553
|
+
// Lets the app detect a stale bridge and say "restart your bridge"
|
|
554
|
+
// instead of silently degrading (older bridges lack newer self-healing).
|
|
555
|
+
version: BRIDGE_VERSION,
|
|
556
|
+
};
|
|
495
557
|
}
|
|
496
558
|
|
|
497
559
|
// Public health probe.
|
|
@@ -549,11 +611,27 @@ const { execSync } = require('child_process');
|
|
|
549
611
|
|
|
550
612
|
let preview = null; // { port, proc, kind, url, tunnel, tunnelProc }
|
|
551
613
|
|
|
614
|
+
// Kill a spawned command's WHOLE process tree. `proc.kill()` alone signals
|
|
615
|
+
// only the immediate child (`sh`/`npm`) — npm does not reliably forward
|
|
616
|
+
// signals, so the actual dev server survives, keeps the port, and the next
|
|
617
|
+
// preview's waitForPort "succeeds" against the STALE server. That's how the
|
|
618
|
+
// auto-fix loop ends up re-reporting an error the agent already fixed.
|
|
619
|
+
function killCmdTree(proc, signal) {
|
|
620
|
+
if (!proc || proc.exitCode !== null) return;
|
|
621
|
+
if (isWindows) {
|
|
622
|
+
try { spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }); }
|
|
623
|
+
catch { try { proc.kill(); } catch { /* already gone */ } }
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
try { process.kill(-proc.pid, signal); }
|
|
627
|
+
catch { try { proc.kill(signal); } catch { /* already gone */ } }
|
|
628
|
+
}
|
|
629
|
+
|
|
552
630
|
function killPreview() {
|
|
553
631
|
if (!preview) return;
|
|
554
|
-
|
|
555
|
-
try { preview.tunnel && preview.tunnel.close(); } catch {}
|
|
556
|
-
try { preview.tunnelProc && preview.tunnelProc.kill('SIGTERM'); } catch {}
|
|
632
|
+
killCmdTree(preview.proc, 'SIGTERM');
|
|
633
|
+
try { preview.tunnel && preview.tunnel.close(); } catch { /* already closed */ }
|
|
634
|
+
try { preview.tunnelProc && preview.tunnelProc.kill('SIGTERM'); } catch { /* already gone */ }
|
|
557
635
|
preview = null;
|
|
558
636
|
}
|
|
559
637
|
|
|
@@ -574,7 +652,7 @@ function waitForPort(port, timeoutMs) {
|
|
|
574
652
|
}
|
|
575
653
|
|
|
576
654
|
function hasCloudflared() {
|
|
577
|
-
try { execSync('which cloudflared', { stdio: 'ignore' }); return true; }
|
|
655
|
+
try { execSync(isWindows ? 'where cloudflared' : 'which cloudflared', { stdio: 'ignore' }); return true; }
|
|
578
656
|
catch { return false; }
|
|
579
657
|
}
|
|
580
658
|
|
|
@@ -587,16 +665,24 @@ function openPreviewTunnel(port) {
|
|
|
587
665
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
588
666
|
});
|
|
589
667
|
let done = false;
|
|
668
|
+
// A spawn failure emits 'error' async — unhandled it would crash the bridge.
|
|
669
|
+
proc.on('error', (e) => {
|
|
670
|
+
console.error('[preview] cloudflared failed to start:', e.message);
|
|
671
|
+
if (!done) { done = true; resolve(null); }
|
|
672
|
+
});
|
|
590
673
|
const scan = (d) => {
|
|
591
674
|
const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
592
675
|
if (m && !done) { done = true; resolve({ url: m[0], kind: 'cloudflared', tunnelProc: proc }); }
|
|
593
676
|
};
|
|
594
677
|
proc.stdout.on('data', scan);
|
|
595
678
|
proc.stderr.on('data', scan);
|
|
596
|
-
setTimeout(() => { if (!done) { done = true; try { proc.kill(); } catch {} resolve(null); } }, 25000);
|
|
679
|
+
setTimeout(() => { if (!done) { done = true; try { proc.kill(); } catch { /* gone */ } resolve(null); } }, 25000);
|
|
597
680
|
} else {
|
|
598
681
|
try {
|
|
599
682
|
const tunnel = await localtunnel({ port });
|
|
683
|
+
// localtunnel handles emit 'error' on connection blips; with no
|
|
684
|
+
// listener that's an uncaught exception that kills the process.
|
|
685
|
+
tunnel.on('error', (e) => console.error('[preview] tunnel error:', e.message));
|
|
600
686
|
resolve({ url: tunnel.url, kind: 'localtunnel', tunnel });
|
|
601
687
|
} catch { resolve(null); }
|
|
602
688
|
}
|
|
@@ -605,7 +691,7 @@ function openPreviewTunnel(port) {
|
|
|
605
691
|
|
|
606
692
|
// Strip ANSI color codes bundlers embed in their error output.
|
|
607
693
|
function stripAnsi(s) {
|
|
608
|
-
return String(s).replace(
|
|
694
|
+
return String(s).replace(/\x1b\[[0-9;]*m/g, '').replace(/\[[0-9;]*m/g, '');
|
|
609
695
|
}
|
|
610
696
|
|
|
611
697
|
// Reduce a bundler error (Metro's JSON payload, or raw stderr text) to a
|
|
@@ -693,12 +779,16 @@ async function probeBuildError(port) {
|
|
|
693
779
|
// { code, out } (code -1 on timeout/spawn error).
|
|
694
780
|
function runInWorkdir(cmd, label, timeoutMs) {
|
|
695
781
|
return new Promise((resolve) => {
|
|
696
|
-
|
|
782
|
+
// Windows has no `sh`; let spawn route through cmd.exe there. POSIX gets
|
|
783
|
+
// its own process group so a timeout kill takes the whole tree.
|
|
784
|
+
const proc = isWindows
|
|
785
|
+
? spawn(cmd, { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: true })
|
|
786
|
+
: spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
697
787
|
let out = '';
|
|
698
788
|
const onData = (d) => { out = (out + d).slice(-20000); process.stdout.write(`[${label}] ${d}`); };
|
|
699
789
|
proc.stdout.on('data', onData);
|
|
700
790
|
proc.stderr.on('data', onData);
|
|
701
|
-
const timer = setTimeout(() => {
|
|
791
|
+
const timer = setTimeout(() => { killCmdTree(proc, 'SIGKILL'); resolve({ code: -1, out }); }, timeoutMs);
|
|
702
792
|
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code, out }); });
|
|
703
793
|
proc.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out: `${out}\n${e.message}` }); });
|
|
704
794
|
});
|
|
@@ -735,18 +825,33 @@ function dropUnresolvableDep(npmOutput) {
|
|
|
735
825
|
// the slow install to Esque (the blueprint says so), so the first preview would
|
|
736
826
|
// otherwise die with "module 'expo' is not installed" / "next: not found".
|
|
737
827
|
// Idempotent — skipped as soon as node_modules exists.
|
|
828
|
+
// Resolves { ok, error }: `error` is a short human/agent-readable summary of
|
|
829
|
+
// WHY installation failed, which flows into the preview's buildError so the
|
|
830
|
+
// phone (and the auto-fix loop, which can rewrite package.json) sees the real
|
|
831
|
+
// cause instead of a generic "couldn't start the preview".
|
|
738
832
|
async function ensureDeps() {
|
|
739
|
-
|
|
833
|
+
const pkgPath = path.join(WORKDIR, 'package.json');
|
|
834
|
+
if (!fs.existsSync(pkgPath)) return { ok: true, error: null }; // not a Node project
|
|
835
|
+
|
|
836
|
+
let pkg;
|
|
740
837
|
try {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
838
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
839
|
+
} catch (e) {
|
|
840
|
+
// A broken package.json must SURFACE, not silently skip the install —
|
|
841
|
+
// everything downstream fails confusingly otherwise.
|
|
842
|
+
return { ok: false, error: `package.json is not valid JSON: ${e.message}` };
|
|
843
|
+
}
|
|
844
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
845
|
+
const isExpo = !!deps.expo;
|
|
846
|
+
|
|
847
|
+
// "Installed" means the project's key dependency RESOLVES — not merely that
|
|
848
|
+
// node_modules exists. A failed install can leave a partial node_modules
|
|
849
|
+
// behind, which would otherwise skip reinstall forever.
|
|
850
|
+
const keyDep = isExpo ? 'expo' : deps.next ? 'next' : Object.keys(pkg.dependencies || {})[0] || null;
|
|
851
|
+
const hasModules = fs.existsSync(path.join(WORKDIR, 'node_modules'));
|
|
852
|
+
const keyResolves =
|
|
853
|
+
!keyDep || fs.existsSync(path.join(WORKDIR, 'node_modules', ...keyDep.split('/')));
|
|
854
|
+
if (hasModules && keyResolves) return { ok: true, error: null };
|
|
750
855
|
|
|
751
856
|
console.log('[preview] installing dependencies — first preview, this can take a few minutes…');
|
|
752
857
|
// `--legacy-peer-deps`: AI-scaffolded RN projects routinely pin a React-18 app
|
|
@@ -754,17 +859,23 @@ async function ensureDeps() {
|
|
|
754
859
|
const INSTALL = 'npm install --legacy-peer-deps --no-audit --no-fund';
|
|
755
860
|
|
|
756
861
|
let ok = false;
|
|
862
|
+
let lastOut = '';
|
|
757
863
|
// Each failed pass may expose one invented version we can drop, then retry.
|
|
758
864
|
// Bounded so a genuinely unfixable project can't loop forever.
|
|
759
865
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
760
866
|
const { code, out } = await runInWorkdir(INSTALL, 'npm', 360000);
|
|
867
|
+
lastOut = out;
|
|
761
868
|
if (code === 0) { ok = true; break; }
|
|
762
869
|
const dropped = dropUnresolvableDep(out);
|
|
763
870
|
if (dropped) { console.log(`[preview] "${dropped}" has no installable version — removed it and retrying…`); continue; }
|
|
764
|
-
console.error(`[preview] npm install failed (exit ${code})
|
|
871
|
+
console.error(`[preview] npm install failed (exit ${code})`);
|
|
765
872
|
break;
|
|
766
873
|
}
|
|
767
|
-
if (!ok)
|
|
874
|
+
if (!ok) {
|
|
875
|
+
const tail = stripAnsi(lastOut).split('\n').filter((l) => /npm error|err!/i.test(l)).slice(0, 6).join('\n')
|
|
876
|
+
|| stripAnsi(lastOut).trim().split('\n').slice(-6).join('\n');
|
|
877
|
+
return { ok: false, error: `npm install failed:\n${tail.slice(0, 600)}` };
|
|
878
|
+
}
|
|
768
879
|
console.log('[preview] dependencies installed');
|
|
769
880
|
|
|
770
881
|
// For Expo apps, realign every SDK-managed package to the version the SDK
|
|
@@ -776,40 +887,72 @@ async function ensureDeps() {
|
|
|
776
887
|
if (code === 0) console.log('[preview] aligned dependency versions to the Expo SDK');
|
|
777
888
|
else console.warn('[preview] could not run expo install --fix — continuing with installed versions');
|
|
778
889
|
}
|
|
779
|
-
return true;
|
|
890
|
+
return { ok: true, error: null };
|
|
780
891
|
}
|
|
781
892
|
|
|
782
893
|
async function startPreview(cmd, port) {
|
|
783
894
|
killPreview();
|
|
784
|
-
// Make sure node_modules exists before the dev server tries to boot.
|
|
785
|
-
|
|
895
|
+
// Make sure node_modules exists before the dev server tries to boot. A
|
|
896
|
+
// failed install IS the build error — surface the npm cause to the phone
|
|
897
|
+
// (and the auto-fix loop, which can repair package.json) instead of letting
|
|
898
|
+
// the dev server fail 60s later with something unrelated.
|
|
899
|
+
const deps = await ensureDeps();
|
|
900
|
+
if (!deps.ok) {
|
|
901
|
+
console.error(`[preview] dependency install failed:\n${deps.error}`);
|
|
902
|
+
return { url: null, buildError: deps.error };
|
|
903
|
+
}
|
|
786
904
|
console.log(`[preview] starting: ${cmd} (port ${port})`);
|
|
787
|
-
|
|
788
|
-
|
|
905
|
+
// Own process group (POSIX) so killPreview can take down the WHOLE tree —
|
|
906
|
+
// `npm run dev` does not forward signals to the real dev server. Windows
|
|
907
|
+
// has no `sh`; route through cmd.exe and kill via taskkill /T.
|
|
908
|
+
const proc = isWindows
|
|
909
|
+
? spawn(cmd, { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: true })
|
|
910
|
+
: spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
911
|
+
proc.on('error', (e) => console.error(`[preview] dev server spawn failed: ${e.message}`));
|
|
912
|
+
// Local handle `p`: this function awaits for minutes, and a NEWER preview
|
|
913
|
+
// may replace/kill the global `preview` meanwhile. Every write below checks
|
|
914
|
+
// `preview === p` so a superseded run can't deref null, clobber the new
|
|
915
|
+
// preview's state, or leak its own processes.
|
|
916
|
+
const p = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null, output: '', buildError: null };
|
|
917
|
+
preview = p;
|
|
789
918
|
// Keep a rolling tail of output so the error scanner has something to read.
|
|
790
919
|
const capture = (d) => {
|
|
791
920
|
process.stdout.write(`[preview] ${d}`);
|
|
792
|
-
|
|
921
|
+
p.output = (p.output + d).slice(-16000);
|
|
793
922
|
};
|
|
794
923
|
proc.stdout.on('data', capture);
|
|
795
924
|
proc.stderr.on('data', capture);
|
|
796
925
|
proc.on('exit', (code) => console.log(`[preview] dev server exited (${code})`));
|
|
797
926
|
|
|
798
927
|
const up = await waitForPort(port, 60000);
|
|
799
|
-
if (
|
|
928
|
+
if (preview !== p) { killCmdTree(proc, 'SIGTERM'); return null; } // superseded
|
|
929
|
+
if (!up) {
|
|
930
|
+
console.log('[preview] dev server never opened the port');
|
|
931
|
+
killPreview(); // don't leave a half-started server holding state
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
800
934
|
|
|
801
935
|
const t = await openPreviewTunnel(port);
|
|
802
|
-
if (
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
936
|
+
if (preview !== p) { // superseded while the tunnel opened
|
|
937
|
+
killCmdTree(proc, 'SIGTERM');
|
|
938
|
+
if (t) { try { t.tunnel && t.tunnel.close(); } catch { /* closed */ } try { t.tunnelProc && t.tunnelProc.kill(); } catch { /* gone */ } }
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
if (!t) {
|
|
942
|
+
console.log('[preview] could not open a public tunnel');
|
|
943
|
+
killPreview(); // the message tells the user to run it themselves — free the port
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
p.url = t.url;
|
|
947
|
+
p.kind = t.kind;
|
|
948
|
+
p.tunnel = t.tunnel || null;
|
|
949
|
+
p.tunnelProc = t.tunnelProc || null;
|
|
807
950
|
console.log(`[preview] live at ${t.url} (${t.kind})`);
|
|
808
951
|
|
|
809
952
|
// Before handing the URL to the phone, make sure the page will actually
|
|
810
953
|
// render — a dev server can be "up" while its bundle fails to compile.
|
|
811
954
|
const buildError = await probeBuildError(port);
|
|
812
|
-
if (preview)
|
|
955
|
+
if (preview === p) p.buildError = buildError;
|
|
813
956
|
if (buildError) console.error(`[preview] ⚠ web build is failing:\n${buildError}`);
|
|
814
957
|
|
|
815
958
|
return { url: t.url, buildError };
|
|
@@ -841,19 +984,22 @@ async function applyPreview(text) {
|
|
|
841
984
|
buildError = result.buildError;
|
|
842
985
|
// Surface the failure as plain text WITHOUT the URL — the app turns any
|
|
843
986
|
// URL in a message into a "Preview Build" pill, and we don't want a pill
|
|
844
|
-
// that just opens a blank page.
|
|
987
|
+
// that just opens a blank page. (Covers both dependency-install failures,
|
|
988
|
+
// where no server is running, and compile failures, where one is.)
|
|
845
989
|
replacement =
|
|
846
|
-
`⚠️ Preview
|
|
990
|
+
`⚠️ Preview failed to build:\n\n` +
|
|
847
991
|
`${result.buildError}\n\n` +
|
|
848
|
-
`Fix that and I'll preview again
|
|
992
|
+
`Fix that and I'll preview again.`;
|
|
849
993
|
} else {
|
|
850
994
|
replacement = `🔗 Live preview: ${result.url}`;
|
|
851
995
|
}
|
|
852
|
-
|
|
996
|
+
// Function replacement: the text can contain `$&`/`$'`-style sequences
|
|
997
|
+
// (shell-ish build errors do), which String.replace would otherwise expand.
|
|
998
|
+
return { text: text.replace(PREVIEW_RE, () => replacement), buildError };
|
|
853
999
|
}
|
|
854
1000
|
|
|
855
1001
|
async function runAgentWithPreview(prompt, sessionId) {
|
|
856
|
-
const result = await
|
|
1002
|
+
const result = await runAgentResilient(prompt, sessionId);
|
|
857
1003
|
if (!result || !result.text) return result;
|
|
858
1004
|
|
|
859
1005
|
const applied = await applyPreview(result.text);
|
|
@@ -872,7 +1018,7 @@ async function runAgentWithPreview(prompt, sessionId) {
|
|
|
872
1018
|
`Fix the root cause in the code, then re-emit the ESQUE_PREVIEW marker on ` +
|
|
873
1019
|
`its own line. Keep the explanation brief — make the fix and output the marker.`;
|
|
874
1020
|
let fix = null;
|
|
875
|
-
try { fix = await
|
|
1021
|
+
try { fix = await runAgentResilient(fixPrompt, sessionId); } catch (e) { console.error('[preview] auto-fix run failed:', e.message); }
|
|
876
1022
|
if (fix && fix.text) {
|
|
877
1023
|
const fixApplied = await applyPreview(fix.text);
|
|
878
1024
|
result.text = `${applied.text}\n\n— Auto-fix attempt —\n${fixApplied.text}`;
|
|
@@ -897,7 +1043,11 @@ function newJobId() {
|
|
|
897
1043
|
function gcJobs() {
|
|
898
1044
|
const now = Date.now();
|
|
899
1045
|
for (const [id, j] of jobs) {
|
|
900
|
-
|
|
1046
|
+
// Never evict a RUNNING job: a first-preview scaffold (agent run +
|
|
1047
|
+
// dependency install + auto-fix) can legitimately exceed the TTL, and
|
|
1048
|
+
// evicting it mid-run makes the phone's poll 404 with a bogus "bridge
|
|
1049
|
+
// may have restarted" while the agent is still working.
|
|
1050
|
+
if (j.status !== 'working' && now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
|
|
901
1051
|
}
|
|
902
1052
|
}
|
|
903
1053
|
// Evict expired jobs even on an idle bridge (the POST path GCs too, but an
|
|
@@ -912,7 +1062,7 @@ setInterval(gcJobs, 60_000).unref?.();
|
|
|
912
1062
|
async function sendExpoPush(token, kind, sessionId, title, message) {
|
|
913
1063
|
if (!token || !String(token).startsWith('ExponentPushToken[')) return;
|
|
914
1064
|
try {
|
|
915
|
-
await fetch('https://exp.host/--/api/v2/push/send', {
|
|
1065
|
+
const r = await fetch('https://exp.host/--/api/v2/push/send', {
|
|
916
1066
|
method: 'POST',
|
|
917
1067
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
918
1068
|
body: JSON.stringify({
|
|
@@ -924,11 +1074,30 @@ async function sendExpoPush(token, kind, sessionId, title, message) {
|
|
|
924
1074
|
data: { kind, sessionId },
|
|
925
1075
|
}),
|
|
926
1076
|
});
|
|
1077
|
+
// Expo reports per-message failures (e.g. DeviceNotRegistered) inside a
|
|
1078
|
+
// 200 response — surface them so dead tokens don't look like delivered
|
|
1079
|
+
// pushes forever.
|
|
1080
|
+
const out = await r.json().catch(() => null);
|
|
1081
|
+
const ticket = out && Array.isArray(out.data) ? out.data[0] : null;
|
|
1082
|
+
if (ticket && ticket.status !== 'ok') {
|
|
1083
|
+
console.warn(`[push] not delivered: ${ticket.message || ticket.details?.error || 'unknown'}`);
|
|
1084
|
+
}
|
|
927
1085
|
} catch (e) {
|
|
928
1086
|
console.error('[push] send failed:', e.message);
|
|
929
1087
|
}
|
|
930
1088
|
}
|
|
931
1089
|
|
|
1090
|
+
// One agent run at a time. Without this, a follow-up prompt sent while the
|
|
1091
|
+
// previous one is still working spawns a SECOND agent mutating the same files,
|
|
1092
|
+
// races two --resume's of the same CLI session, and collides in the preview
|
|
1093
|
+
// pipeline. A simple promise chain keeps arrival order and isolates failures.
|
|
1094
|
+
let runQueue = Promise.resolve();
|
|
1095
|
+
function enqueueRun(prompt, sessionId) {
|
|
1096
|
+
const run = runQueue.then(() => runAgentWithPreview(prompt, sessionId));
|
|
1097
|
+
runQueue = run.then(() => undefined, () => undefined);
|
|
1098
|
+
return run;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
932
1101
|
async function executeHandler(req, res) {
|
|
933
1102
|
const body = req.body || {};
|
|
934
1103
|
const prompt = String(body.prompt || '');
|
|
@@ -954,7 +1123,7 @@ async function executeHandler(req, res) {
|
|
|
954
1123
|
const jobId = newJobId();
|
|
955
1124
|
jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
|
|
956
1125
|
res.json({ jobId, status: 'working' });
|
|
957
|
-
|
|
1126
|
+
enqueueRun(prompt, esqueSessionId)
|
|
958
1127
|
.then((result) => {
|
|
959
1128
|
jobs.set(jobId, {
|
|
960
1129
|
status: result.isError ? 'blocked' : 'finished',
|
|
@@ -1009,7 +1178,7 @@ async function executeHandler(req, res) {
|
|
|
1009
1178
|
};
|
|
1010
1179
|
|
|
1011
1180
|
try {
|
|
1012
|
-
const result = await
|
|
1181
|
+
const result = await enqueueRun(prompt, esqueSessionId);
|
|
1013
1182
|
finish({
|
|
1014
1183
|
text: result.text,
|
|
1015
1184
|
status: result.isError ? 'blocked' : 'finished',
|
|
@@ -1097,6 +1266,50 @@ function listenOnFreePort(app, startPort, maxTries = 25) {
|
|
|
1097
1266
|
});
|
|
1098
1267
|
}
|
|
1099
1268
|
|
|
1269
|
+
// Open the public pairing tunnel. Prefers cloudflared (reliable, no
|
|
1270
|
+
// interstitial), and falls back to localtunnel with a HARD timeout — otherwise
|
|
1271
|
+
// a slow/down localtunnel.me hangs the whole bridge before the QR ever prints
|
|
1272
|
+
// (exactly the "nothing happens after y" failure). Resolves a uniform handle
|
|
1273
|
+
// { url, kind, close(), onClose(cb) }, or null if both paths fail.
|
|
1274
|
+
function openPairTunnel(port) {
|
|
1275
|
+
return new Promise((resolve) => {
|
|
1276
|
+
let settled = false;
|
|
1277
|
+
const done = (val) => { if (!settled) { settled = true; resolve(val); } };
|
|
1278
|
+
|
|
1279
|
+
const tryLocaltunnel = () => {
|
|
1280
|
+
const timer = setTimeout(() => done(null), 20000);
|
|
1281
|
+
localtunnel({ port, subdomain: LT_SUBDOMAIN })
|
|
1282
|
+
.then((t) => {
|
|
1283
|
+
clearTimeout(timer);
|
|
1284
|
+
// localtunnel handles emit 'error' on connection blips; unhandled,
|
|
1285
|
+
// that's an uncaught exception that kills the bridge.
|
|
1286
|
+
t.on('error', (e) => console.error('[bridge] pair tunnel error:', e.message));
|
|
1287
|
+
if (settled) { try { t.close(); } catch { /* already gone */ } return; }
|
|
1288
|
+
done({ url: t.url, kind: 'localtunnel', close: () => { try { t.close(); } catch { /* already gone */ } }, onClose: (cb) => t.on('close', cb) });
|
|
1289
|
+
})
|
|
1290
|
+
.catch(() => { clearTimeout(timer); done(null); });
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
if (!hasCloudflared()) { tryLocaltunnel(); return; }
|
|
1294
|
+
|
|
1295
|
+
const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1296
|
+
let gotUrl = false, fellBack = false;
|
|
1297
|
+
const scan = (d) => {
|
|
1298
|
+
const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
1299
|
+
if (m && !gotUrl) {
|
|
1300
|
+
gotUrl = true;
|
|
1301
|
+
done({ url: m[0], kind: 'cloudflared', close: () => { try { proc.kill(); } catch { /* already gone */ } }, onClose: (cb) => proc.on('exit', cb) });
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
proc.stdout.on('data', scan);
|
|
1305
|
+
proc.stderr.on('data', scan);
|
|
1306
|
+
// cloudflared died before emitting a URL, or took too long → fall back once.
|
|
1307
|
+
const fallback = () => { if (!gotUrl && !fellBack) { fellBack = true; try { proc.kill(); } catch { /* already gone */ } tryLocaltunnel(); } };
|
|
1308
|
+
proc.on('exit', fallback);
|
|
1309
|
+
setTimeout(fallback, 25000);
|
|
1310
|
+
});
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1100
1313
|
async function main() {
|
|
1101
1314
|
if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
|
|
1102
1315
|
console.error(`workdir does not exist: ${WORKDIR}`);
|
|
@@ -1136,15 +1349,17 @@ async function main() {
|
|
|
1136
1349
|
console.log(` ✓ Using port ${boundPort} instead (${PORT} was taken).`);
|
|
1137
1350
|
}
|
|
1138
1351
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
console.error('
|
|
1144
|
-
console.error('
|
|
1145
|
-
console.error(` cloudflared tunnel --url http://localhost:${boundPort}`);
|
|
1352
|
+
const tunnel = await openPairTunnel(boundPort);
|
|
1353
|
+
if (!tunnel) {
|
|
1354
|
+
console.error('\n ✗ Could not open a public tunnel.');
|
|
1355
|
+
console.error(' cloudflared and localtunnel.me both failed to respond.');
|
|
1356
|
+
console.error(' Check your internet connection and retry. For the reliable path:');
|
|
1357
|
+
console.error(' brew install cloudflared');
|
|
1146
1358
|
process.exit(1);
|
|
1147
1359
|
}
|
|
1360
|
+
if (tunnel.kind === 'localtunnel') {
|
|
1361
|
+
console.log(' (Using localtunnel — install cloudflared for a faster, steadier tunnel: brew install cloudflared)');
|
|
1362
|
+
}
|
|
1148
1363
|
|
|
1149
1364
|
const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}&agent=${AGENT_TYPE}`;
|
|
1150
1365
|
|
|
@@ -1169,8 +1384,14 @@ async function main() {
|
|
|
1169
1384
|
console.log(' Press Ctrl-C to stop.');
|
|
1170
1385
|
console.log('━'.repeat(68));
|
|
1171
1386
|
|
|
1387
|
+
let shuttingDown = false;
|
|
1172
1388
|
const shutdown = (signal) => {
|
|
1389
|
+
shuttingDown = true;
|
|
1173
1390
|
console.log(`\n Received ${signal} — closing tunnel…`);
|
|
1391
|
+
// The agent runs detached in its own process group, so the terminal's
|
|
1392
|
+
// Ctrl-C does NOT reach it — stop it explicitly or it keeps editing
|
|
1393
|
+
// files with no bridge attached.
|
|
1394
|
+
try { activeAgentKill && activeAgentKill('SIGTERM'); } catch { /* already gone */ }
|
|
1174
1395
|
killPreview();
|
|
1175
1396
|
try {
|
|
1176
1397
|
tunnel.close();
|
|
@@ -1182,7 +1403,20 @@ async function main() {
|
|
|
1182
1403
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1183
1404
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1184
1405
|
|
|
1185
|
-
|
|
1406
|
+
// Crash safety net: without this, an unexpected throw leaves the dev
|
|
1407
|
+
// server, both cloudflared tunnels, and a detached agent running headless.
|
|
1408
|
+
process.on('uncaughtException', (err) => {
|
|
1409
|
+
console.error('\n ✗ Unexpected error:', err && err.stack ? err.stack : err);
|
|
1410
|
+
try { activeAgentKill && activeAgentKill('SIGTERM'); } catch { /* gone */ }
|
|
1411
|
+
killPreview();
|
|
1412
|
+
try { tunnel.close(); } catch { /* closed */ }
|
|
1413
|
+
process.exit(1);
|
|
1414
|
+
});
|
|
1415
|
+
|
|
1416
|
+
// Detect an unexpected tunnel drop (but stay quiet during our own shutdown,
|
|
1417
|
+
// since closing the tunnel also fires this).
|
|
1418
|
+
tunnel.onClose(() => {
|
|
1419
|
+
if (shuttingDown) return;
|
|
1186
1420
|
console.error('\nTunnel closed unexpectedly. Restart `esque-bridge`.');
|
|
1187
1421
|
process.exit(1);
|
|
1188
1422
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "esque-bridge",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
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"
|