esque-bridge 0.6.8 → 0.6.10
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 +310 -51
- 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,53 @@ const { execSync } = require('child_process');
|
|
|
549
611
|
|
|
550
612
|
let preview = null; // { port, proc, kind, url, tunnel, tunnelProc }
|
|
551
613
|
|
|
614
|
+
// ── Preview persistence ───────────────────────────────────────────────
|
|
615
|
+
// Tunnels die with the laptop lid / a bridge restart, and the URL embedded in
|
|
616
|
+
// the phone's chat history can never come back (quick-tunnel URLs are random).
|
|
617
|
+
// Remember each workdir's last preview COMMAND so a restarted bridge can
|
|
618
|
+
// revive the dev server + mint a fresh tunnel — the phone then resolves the
|
|
619
|
+
// current URL via GET /preview instead of trusting the dead one.
|
|
620
|
+
const PREVIEWS_FILE = path.join(os.homedir(), '.esque-bridge-previews.json');
|
|
621
|
+
function savePreviewCmd(cmd, port) {
|
|
622
|
+
try {
|
|
623
|
+
let all = {};
|
|
624
|
+
try { all = JSON.parse(fs.readFileSync(PREVIEWS_FILE, 'utf8')); } catch { /* first run */ }
|
|
625
|
+
all[WORKDIR] = { cmd, port };
|
|
626
|
+
fs.writeFileSync(PREVIEWS_FILE, JSON.stringify(all, null, 2));
|
|
627
|
+
} catch (err) {
|
|
628
|
+
console.warn('[preview] could not persist preview command:', err.message);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
function loadPreviewCmd() {
|
|
632
|
+
try {
|
|
633
|
+
const all = JSON.parse(fs.readFileSync(PREVIEWS_FILE, 'utf8'));
|
|
634
|
+
const e = all[WORKDIR];
|
|
635
|
+
if (e && typeof e.cmd === 'string' && Number.isInteger(e.port)) return e;
|
|
636
|
+
} catch { /* none saved */ }
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Kill a spawned command's WHOLE process tree. `proc.kill()` alone signals
|
|
641
|
+
// only the immediate child (`sh`/`npm`) — npm does not reliably forward
|
|
642
|
+
// signals, so the actual dev server survives, keeps the port, and the next
|
|
643
|
+
// preview's waitForPort "succeeds" against the STALE server. That's how the
|
|
644
|
+
// auto-fix loop ends up re-reporting an error the agent already fixed.
|
|
645
|
+
function killCmdTree(proc, signal) {
|
|
646
|
+
if (!proc || proc.exitCode !== null) return;
|
|
647
|
+
if (isWindows) {
|
|
648
|
+
try { spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { stdio: 'ignore' }); }
|
|
649
|
+
catch { try { proc.kill(); } catch { /* already gone */ } }
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
try { process.kill(-proc.pid, signal); }
|
|
653
|
+
catch { try { proc.kill(signal); } catch { /* already gone */ } }
|
|
654
|
+
}
|
|
655
|
+
|
|
552
656
|
function killPreview() {
|
|
553
657
|
if (!preview) return;
|
|
554
|
-
|
|
555
|
-
try { preview.tunnel && preview.tunnel.close(); } catch {}
|
|
556
|
-
try { preview.tunnelProc && preview.tunnelProc.kill('SIGTERM'); } catch {}
|
|
658
|
+
killCmdTree(preview.proc, 'SIGTERM');
|
|
659
|
+
try { preview.tunnel && preview.tunnel.close(); } catch { /* already closed */ }
|
|
660
|
+
try { preview.tunnelProc && preview.tunnelProc.kill('SIGTERM'); } catch { /* already gone */ }
|
|
557
661
|
preview = null;
|
|
558
662
|
}
|
|
559
663
|
|
|
@@ -574,7 +678,7 @@ function waitForPort(port, timeoutMs) {
|
|
|
574
678
|
}
|
|
575
679
|
|
|
576
680
|
function hasCloudflared() {
|
|
577
|
-
try { execSync('which cloudflared', { stdio: 'ignore' }); return true; }
|
|
681
|
+
try { execSync(isWindows ? 'where cloudflared' : 'which cloudflared', { stdio: 'ignore' }); return true; }
|
|
578
682
|
catch { return false; }
|
|
579
683
|
}
|
|
580
684
|
|
|
@@ -587,16 +691,24 @@ function openPreviewTunnel(port) {
|
|
|
587
691
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
588
692
|
});
|
|
589
693
|
let done = false;
|
|
694
|
+
// A spawn failure emits 'error' async — unhandled it would crash the bridge.
|
|
695
|
+
proc.on('error', (e) => {
|
|
696
|
+
console.error('[preview] cloudflared failed to start:', e.message);
|
|
697
|
+
if (!done) { done = true; resolve(null); }
|
|
698
|
+
});
|
|
590
699
|
const scan = (d) => {
|
|
591
700
|
const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
592
701
|
if (m && !done) { done = true; resolve({ url: m[0], kind: 'cloudflared', tunnelProc: proc }); }
|
|
593
702
|
};
|
|
594
703
|
proc.stdout.on('data', scan);
|
|
595
704
|
proc.stderr.on('data', scan);
|
|
596
|
-
setTimeout(() => { if (!done) { done = true; try { proc.kill(); } catch {} resolve(null); } }, 25000);
|
|
705
|
+
setTimeout(() => { if (!done) { done = true; try { proc.kill(); } catch { /* gone */ } resolve(null); } }, 25000);
|
|
597
706
|
} else {
|
|
598
707
|
try {
|
|
599
708
|
const tunnel = await localtunnel({ port });
|
|
709
|
+
// localtunnel handles emit 'error' on connection blips; with no
|
|
710
|
+
// listener that's an uncaught exception that kills the process.
|
|
711
|
+
tunnel.on('error', (e) => console.error('[preview] tunnel error:', e.message));
|
|
600
712
|
resolve({ url: tunnel.url, kind: 'localtunnel', tunnel });
|
|
601
713
|
} catch { resolve(null); }
|
|
602
714
|
}
|
|
@@ -605,7 +717,7 @@ function openPreviewTunnel(port) {
|
|
|
605
717
|
|
|
606
718
|
// Strip ANSI color codes bundlers embed in their error output.
|
|
607
719
|
function stripAnsi(s) {
|
|
608
|
-
return String(s).replace(
|
|
720
|
+
return String(s).replace(/\x1b\[[0-9;]*m/g, '').replace(/\[[0-9;]*m/g, '');
|
|
609
721
|
}
|
|
610
722
|
|
|
611
723
|
// Reduce a bundler error (Metro's JSON payload, or raw stderr text) to a
|
|
@@ -693,12 +805,16 @@ async function probeBuildError(port) {
|
|
|
693
805
|
// { code, out } (code -1 on timeout/spawn error).
|
|
694
806
|
function runInWorkdir(cmd, label, timeoutMs) {
|
|
695
807
|
return new Promise((resolve) => {
|
|
696
|
-
|
|
808
|
+
// Windows has no `sh`; let spawn route through cmd.exe there. POSIX gets
|
|
809
|
+
// its own process group so a timeout kill takes the whole tree.
|
|
810
|
+
const proc = isWindows
|
|
811
|
+
? spawn(cmd, { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: true })
|
|
812
|
+
: spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
697
813
|
let out = '';
|
|
698
814
|
const onData = (d) => { out = (out + d).slice(-20000); process.stdout.write(`[${label}] ${d}`); };
|
|
699
815
|
proc.stdout.on('data', onData);
|
|
700
816
|
proc.stderr.on('data', onData);
|
|
701
|
-
const timer = setTimeout(() => {
|
|
817
|
+
const timer = setTimeout(() => { killCmdTree(proc, 'SIGKILL'); resolve({ code: -1, out }); }, timeoutMs);
|
|
702
818
|
proc.on('exit', (code) => { clearTimeout(timer); resolve({ code, out }); });
|
|
703
819
|
proc.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out: `${out}\n${e.message}` }); });
|
|
704
820
|
});
|
|
@@ -735,18 +851,33 @@ function dropUnresolvableDep(npmOutput) {
|
|
|
735
851
|
// the slow install to Esque (the blueprint says so), so the first preview would
|
|
736
852
|
// otherwise die with "module 'expo' is not installed" / "next: not found".
|
|
737
853
|
// Idempotent — skipped as soon as node_modules exists.
|
|
854
|
+
// Resolves { ok, error }: `error` is a short human/agent-readable summary of
|
|
855
|
+
// WHY installation failed, which flows into the preview's buildError so the
|
|
856
|
+
// phone (and the auto-fix loop, which can rewrite package.json) sees the real
|
|
857
|
+
// cause instead of a generic "couldn't start the preview".
|
|
738
858
|
async function ensureDeps() {
|
|
739
|
-
|
|
859
|
+
const pkgPath = path.join(WORKDIR, 'package.json');
|
|
860
|
+
if (!fs.existsSync(pkgPath)) return { ok: true, error: null }; // not a Node project
|
|
861
|
+
|
|
862
|
+
let pkg;
|
|
740
863
|
try {
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
864
|
+
pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
|
865
|
+
} catch (e) {
|
|
866
|
+
// A broken package.json must SURFACE, not silently skip the install —
|
|
867
|
+
// everything downstream fails confusingly otherwise.
|
|
868
|
+
return { ok: false, error: `package.json is not valid JSON: ${e.message}` };
|
|
869
|
+
}
|
|
870
|
+
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
|
871
|
+
const isExpo = !!deps.expo;
|
|
872
|
+
|
|
873
|
+
// "Installed" means the project's key dependency RESOLVES — not merely that
|
|
874
|
+
// node_modules exists. A failed install can leave a partial node_modules
|
|
875
|
+
// behind, which would otherwise skip reinstall forever.
|
|
876
|
+
const keyDep = isExpo ? 'expo' : deps.next ? 'next' : Object.keys(pkg.dependencies || {})[0] || null;
|
|
877
|
+
const hasModules = fs.existsSync(path.join(WORKDIR, 'node_modules'));
|
|
878
|
+
const keyResolves =
|
|
879
|
+
!keyDep || fs.existsSync(path.join(WORKDIR, 'node_modules', ...keyDep.split('/')));
|
|
880
|
+
if (hasModules && keyResolves) return { ok: true, error: null };
|
|
750
881
|
|
|
751
882
|
console.log('[preview] installing dependencies — first preview, this can take a few minutes…');
|
|
752
883
|
// `--legacy-peer-deps`: AI-scaffolded RN projects routinely pin a React-18 app
|
|
@@ -754,17 +885,23 @@ async function ensureDeps() {
|
|
|
754
885
|
const INSTALL = 'npm install --legacy-peer-deps --no-audit --no-fund';
|
|
755
886
|
|
|
756
887
|
let ok = false;
|
|
888
|
+
let lastOut = '';
|
|
757
889
|
// Each failed pass may expose one invented version we can drop, then retry.
|
|
758
890
|
// Bounded so a genuinely unfixable project can't loop forever.
|
|
759
891
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
760
892
|
const { code, out } = await runInWorkdir(INSTALL, 'npm', 360000);
|
|
893
|
+
lastOut = out;
|
|
761
894
|
if (code === 0) { ok = true; break; }
|
|
762
895
|
const dropped = dropUnresolvableDep(out);
|
|
763
896
|
if (dropped) { console.log(`[preview] "${dropped}" has no installable version — removed it and retrying…`); continue; }
|
|
764
|
-
console.error(`[preview] npm install failed (exit ${code})
|
|
897
|
+
console.error(`[preview] npm install failed (exit ${code})`);
|
|
765
898
|
break;
|
|
766
899
|
}
|
|
767
|
-
if (!ok)
|
|
900
|
+
if (!ok) {
|
|
901
|
+
const tail = stripAnsi(lastOut).split('\n').filter((l) => /npm error|err!/i.test(l)).slice(0, 6).join('\n')
|
|
902
|
+
|| stripAnsi(lastOut).trim().split('\n').slice(-6).join('\n');
|
|
903
|
+
return { ok: false, error: `npm install failed:\n${tail.slice(0, 600)}` };
|
|
904
|
+
}
|
|
768
905
|
console.log('[preview] dependencies installed');
|
|
769
906
|
|
|
770
907
|
// For Expo apps, realign every SDK-managed package to the version the SDK
|
|
@@ -776,40 +913,75 @@ async function ensureDeps() {
|
|
|
776
913
|
if (code === 0) console.log('[preview] aligned dependency versions to the Expo SDK');
|
|
777
914
|
else console.warn('[preview] could not run expo install --fix — continuing with installed versions');
|
|
778
915
|
}
|
|
779
|
-
return true;
|
|
916
|
+
return { ok: true, error: null };
|
|
780
917
|
}
|
|
781
918
|
|
|
782
919
|
async function startPreview(cmd, port) {
|
|
783
920
|
killPreview();
|
|
784
|
-
// Make sure node_modules exists before the dev server tries to boot.
|
|
785
|
-
|
|
921
|
+
// Make sure node_modules exists before the dev server tries to boot. A
|
|
922
|
+
// failed install IS the build error — surface the npm cause to the phone
|
|
923
|
+
// (and the auto-fix loop, which can repair package.json) instead of letting
|
|
924
|
+
// the dev server fail 60s later with something unrelated.
|
|
925
|
+
const deps = await ensureDeps();
|
|
926
|
+
if (!deps.ok) {
|
|
927
|
+
console.error(`[preview] dependency install failed:\n${deps.error}`);
|
|
928
|
+
return { url: null, buildError: deps.error };
|
|
929
|
+
}
|
|
786
930
|
console.log(`[preview] starting: ${cmd} (port ${port})`);
|
|
787
|
-
|
|
788
|
-
|
|
931
|
+
// Remember the command now (not on success): a revival attempt after a
|
|
932
|
+
// mid-start crash should still know what to run.
|
|
933
|
+
savePreviewCmd(cmd, port);
|
|
934
|
+
// Own process group (POSIX) so killPreview can take down the WHOLE tree —
|
|
935
|
+
// `npm run dev` does not forward signals to the real dev server. Windows
|
|
936
|
+
// has no `sh`; route through cmd.exe and kill via taskkill /T.
|
|
937
|
+
const proc = isWindows
|
|
938
|
+
? spawn(cmd, { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: true })
|
|
939
|
+
: spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], detached: true });
|
|
940
|
+
proc.on('error', (e) => console.error(`[preview] dev server spawn failed: ${e.message}`));
|
|
941
|
+
// Local handle `p`: this function awaits for minutes, and a NEWER preview
|
|
942
|
+
// may replace/kill the global `preview` meanwhile. Every write below checks
|
|
943
|
+
// `preview === p` so a superseded run can't deref null, clobber the new
|
|
944
|
+
// preview's state, or leak its own processes.
|
|
945
|
+
const p = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null, output: '', buildError: null };
|
|
946
|
+
preview = p;
|
|
789
947
|
// Keep a rolling tail of output so the error scanner has something to read.
|
|
790
948
|
const capture = (d) => {
|
|
791
949
|
process.stdout.write(`[preview] ${d}`);
|
|
792
|
-
|
|
950
|
+
p.output = (p.output + d).slice(-16000);
|
|
793
951
|
};
|
|
794
952
|
proc.stdout.on('data', capture);
|
|
795
953
|
proc.stderr.on('data', capture);
|
|
796
954
|
proc.on('exit', (code) => console.log(`[preview] dev server exited (${code})`));
|
|
797
955
|
|
|
798
956
|
const up = await waitForPort(port, 60000);
|
|
799
|
-
if (
|
|
957
|
+
if (preview !== p) { killCmdTree(proc, 'SIGTERM'); return null; } // superseded
|
|
958
|
+
if (!up) {
|
|
959
|
+
console.log('[preview] dev server never opened the port');
|
|
960
|
+
killPreview(); // don't leave a half-started server holding state
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
800
963
|
|
|
801
964
|
const t = await openPreviewTunnel(port);
|
|
802
|
-
if (
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
965
|
+
if (preview !== p) { // superseded while the tunnel opened
|
|
966
|
+
killCmdTree(proc, 'SIGTERM');
|
|
967
|
+
if (t) { try { t.tunnel && t.tunnel.close(); } catch { /* closed */ } try { t.tunnelProc && t.tunnelProc.kill(); } catch { /* gone */ } }
|
|
968
|
+
return null;
|
|
969
|
+
}
|
|
970
|
+
if (!t) {
|
|
971
|
+
console.log('[preview] could not open a public tunnel');
|
|
972
|
+
killPreview(); // the message tells the user to run it themselves — free the port
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
p.url = t.url;
|
|
976
|
+
p.kind = t.kind;
|
|
977
|
+
p.tunnel = t.tunnel || null;
|
|
978
|
+
p.tunnelProc = t.tunnelProc || null;
|
|
807
979
|
console.log(`[preview] live at ${t.url} (${t.kind})`);
|
|
808
980
|
|
|
809
981
|
// Before handing the URL to the phone, make sure the page will actually
|
|
810
982
|
// render — a dev server can be "up" while its bundle fails to compile.
|
|
811
983
|
const buildError = await probeBuildError(port);
|
|
812
|
-
if (preview)
|
|
984
|
+
if (preview === p) p.buildError = buildError;
|
|
813
985
|
if (buildError) console.error(`[preview] ⚠ web build is failing:\n${buildError}`);
|
|
814
986
|
|
|
815
987
|
return { url: t.url, buildError };
|
|
@@ -841,19 +1013,22 @@ async function applyPreview(text) {
|
|
|
841
1013
|
buildError = result.buildError;
|
|
842
1014
|
// Surface the failure as plain text WITHOUT the URL — the app turns any
|
|
843
1015
|
// URL in a message into a "Preview Build" pill, and we don't want a pill
|
|
844
|
-
// that just opens a blank page.
|
|
1016
|
+
// that just opens a blank page. (Covers both dependency-install failures,
|
|
1017
|
+
// where no server is running, and compile failures, where one is.)
|
|
845
1018
|
replacement =
|
|
846
|
-
`⚠️ Preview
|
|
1019
|
+
`⚠️ Preview failed to build:\n\n` +
|
|
847
1020
|
`${result.buildError}\n\n` +
|
|
848
|
-
`Fix that and I'll preview again
|
|
1021
|
+
`Fix that and I'll preview again.`;
|
|
849
1022
|
} else {
|
|
850
1023
|
replacement = `🔗 Live preview: ${result.url}`;
|
|
851
1024
|
}
|
|
852
|
-
|
|
1025
|
+
// Function replacement: the text can contain `$&`/`$'`-style sequences
|
|
1026
|
+
// (shell-ish build errors do), which String.replace would otherwise expand.
|
|
1027
|
+
return { text: text.replace(PREVIEW_RE, () => replacement), buildError };
|
|
853
1028
|
}
|
|
854
1029
|
|
|
855
1030
|
async function runAgentWithPreview(prompt, sessionId) {
|
|
856
|
-
const result = await
|
|
1031
|
+
const result = await runAgentResilient(prompt, sessionId);
|
|
857
1032
|
if (!result || !result.text) return result;
|
|
858
1033
|
|
|
859
1034
|
const applied = await applyPreview(result.text);
|
|
@@ -872,7 +1047,7 @@ async function runAgentWithPreview(prompt, sessionId) {
|
|
|
872
1047
|
`Fix the root cause in the code, then re-emit the ESQUE_PREVIEW marker on ` +
|
|
873
1048
|
`its own line. Keep the explanation brief — make the fix and output the marker.`;
|
|
874
1049
|
let fix = null;
|
|
875
|
-
try { fix = await
|
|
1050
|
+
try { fix = await runAgentResilient(fixPrompt, sessionId); } catch (e) { console.error('[preview] auto-fix run failed:', e.message); }
|
|
876
1051
|
if (fix && fix.text) {
|
|
877
1052
|
const fixApplied = await applyPreview(fix.text);
|
|
878
1053
|
result.text = `${applied.text}\n\n— Auto-fix attempt —\n${fixApplied.text}`;
|
|
@@ -897,7 +1072,11 @@ function newJobId() {
|
|
|
897
1072
|
function gcJobs() {
|
|
898
1073
|
const now = Date.now();
|
|
899
1074
|
for (const [id, j] of jobs) {
|
|
900
|
-
|
|
1075
|
+
// Never evict a RUNNING job: a first-preview scaffold (agent run +
|
|
1076
|
+
// dependency install + auto-fix) can legitimately exceed the TTL, and
|
|
1077
|
+
// evicting it mid-run makes the phone's poll 404 with a bogus "bridge
|
|
1078
|
+
// may have restarted" while the agent is still working.
|
|
1079
|
+
if (j.status !== 'working' && now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
|
|
901
1080
|
}
|
|
902
1081
|
}
|
|
903
1082
|
// Evict expired jobs even on an idle bridge (the POST path GCs too, but an
|
|
@@ -912,7 +1091,7 @@ setInterval(gcJobs, 60_000).unref?.();
|
|
|
912
1091
|
async function sendExpoPush(token, kind, sessionId, title, message) {
|
|
913
1092
|
if (!token || !String(token).startsWith('ExponentPushToken[')) return;
|
|
914
1093
|
try {
|
|
915
|
-
await fetch('https://exp.host/--/api/v2/push/send', {
|
|
1094
|
+
const r = await fetch('https://exp.host/--/api/v2/push/send', {
|
|
916
1095
|
method: 'POST',
|
|
917
1096
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
918
1097
|
body: JSON.stringify({
|
|
@@ -924,11 +1103,30 @@ async function sendExpoPush(token, kind, sessionId, title, message) {
|
|
|
924
1103
|
data: { kind, sessionId },
|
|
925
1104
|
}),
|
|
926
1105
|
});
|
|
1106
|
+
// Expo reports per-message failures (e.g. DeviceNotRegistered) inside a
|
|
1107
|
+
// 200 response — surface them so dead tokens don't look like delivered
|
|
1108
|
+
// pushes forever.
|
|
1109
|
+
const out = await r.json().catch(() => null);
|
|
1110
|
+
const ticket = out && Array.isArray(out.data) ? out.data[0] : null;
|
|
1111
|
+
if (ticket && ticket.status !== 'ok') {
|
|
1112
|
+
console.warn(`[push] not delivered: ${ticket.message || ticket.details?.error || 'unknown'}`);
|
|
1113
|
+
}
|
|
927
1114
|
} catch (e) {
|
|
928
1115
|
console.error('[push] send failed:', e.message);
|
|
929
1116
|
}
|
|
930
1117
|
}
|
|
931
1118
|
|
|
1119
|
+
// One agent run at a time. Without this, a follow-up prompt sent while the
|
|
1120
|
+
// previous one is still working spawns a SECOND agent mutating the same files,
|
|
1121
|
+
// races two --resume's of the same CLI session, and collides in the preview
|
|
1122
|
+
// pipeline. A simple promise chain keeps arrival order and isolates failures.
|
|
1123
|
+
let runQueue = Promise.resolve();
|
|
1124
|
+
function enqueueRun(prompt, sessionId) {
|
|
1125
|
+
const run = runQueue.then(() => runAgentWithPreview(prompt, sessionId));
|
|
1126
|
+
runQueue = run.then(() => undefined, () => undefined);
|
|
1127
|
+
return run;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
932
1130
|
async function executeHandler(req, res) {
|
|
933
1131
|
const body = req.body || {};
|
|
934
1132
|
const prompt = String(body.prompt || '');
|
|
@@ -954,7 +1152,7 @@ async function executeHandler(req, res) {
|
|
|
954
1152
|
const jobId = newJobId();
|
|
955
1153
|
jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
|
|
956
1154
|
res.json({ jobId, status: 'working' });
|
|
957
|
-
|
|
1155
|
+
enqueueRun(prompt, esqueSessionId)
|
|
958
1156
|
.then((result) => {
|
|
959
1157
|
jobs.set(jobId, {
|
|
960
1158
|
status: result.isError ? 'blocked' : 'finished',
|
|
@@ -1009,7 +1207,7 @@ async function executeHandler(req, res) {
|
|
|
1009
1207
|
};
|
|
1010
1208
|
|
|
1011
1209
|
try {
|
|
1012
|
-
const result = await
|
|
1210
|
+
const result = await enqueueRun(prompt, esqueSessionId);
|
|
1013
1211
|
finish({
|
|
1014
1212
|
text: result.text,
|
|
1015
1213
|
status: result.isError ? 'blocked' : 'finished',
|
|
@@ -1033,6 +1231,45 @@ function resultHandler(req, res) {
|
|
|
1033
1231
|
res.json({ status: job.status, text: job.text });
|
|
1034
1232
|
}
|
|
1035
1233
|
|
|
1234
|
+
// Single-flight wrapper for reviving the saved preview: GET /preview and the
|
|
1235
|
+
// boot-time revival can race; both should await the same start, never two.
|
|
1236
|
+
let previewStartPromise = null;
|
|
1237
|
+
function revivePreview() {
|
|
1238
|
+
if (preview && preview.url) return Promise.resolve({ url: preview.url, buildError: preview.buildError });
|
|
1239
|
+
if (!previewStartPromise) {
|
|
1240
|
+
const saved = loadPreviewCmd();
|
|
1241
|
+
if (!saved) return Promise.resolve(null);
|
|
1242
|
+
console.log('[preview] reviving last preview for this folder…');
|
|
1243
|
+
previewStartPromise = startPreview(saved.cmd, saved.port)
|
|
1244
|
+
.catch((e) => { console.error('[preview] revive failed:', e.message); return null; })
|
|
1245
|
+
.finally(() => { previewStartPromise = null; });
|
|
1246
|
+
}
|
|
1247
|
+
return previewStartPromise;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Current-preview resolver. The phone calls this when a stored preview URL
|
|
1251
|
+
// goes dead (laptop slept → tunnel gone): if the dev server isn't running,
|
|
1252
|
+
// we restart the last one for this folder and answer with the FRESH url.
|
|
1253
|
+
// Response: { ok, url?, port?, building?, buildError? } — 404 when this
|
|
1254
|
+
// folder has never had a preview.
|
|
1255
|
+
app.get('/preview', requireAuth, async (_req, res) => {
|
|
1256
|
+
if (preview && preview.url) {
|
|
1257
|
+
return res.json({ ok: true, url: preview.url, port: preview.port, buildError: preview.buildError ?? null });
|
|
1258
|
+
}
|
|
1259
|
+
if (!loadPreviewCmd() && !previewStartPromise) {
|
|
1260
|
+
return res.status(404).json({ ok: false, error: 'no_preview_for_this_folder' });
|
|
1261
|
+
}
|
|
1262
|
+
const result = await revivePreview();
|
|
1263
|
+
if (result && result.url) {
|
|
1264
|
+
return res.json({ ok: true, url: result.url, port: preview?.port ?? null, buildError: result.buildError ?? null });
|
|
1265
|
+
}
|
|
1266
|
+
return res.status(503).json({
|
|
1267
|
+
ok: false,
|
|
1268
|
+
error: 'preview_failed_to_start',
|
|
1269
|
+
buildError: (result && result.buildError) || null,
|
|
1270
|
+
});
|
|
1271
|
+
});
|
|
1272
|
+
|
|
1036
1273
|
app.post('/execute', requireAuth, executeHandler);
|
|
1037
1274
|
app.post('/', requireAuth, executeHandler);
|
|
1038
1275
|
app.get('/result/:id', requireAuth, resultHandler);
|
|
@@ -1112,6 +1349,9 @@ function openPairTunnel(port) {
|
|
|
1112
1349
|
localtunnel({ port, subdomain: LT_SUBDOMAIN })
|
|
1113
1350
|
.then((t) => {
|
|
1114
1351
|
clearTimeout(timer);
|
|
1352
|
+
// localtunnel handles emit 'error' on connection blips; unhandled,
|
|
1353
|
+
// that's an uncaught exception that kills the bridge.
|
|
1354
|
+
t.on('error', (e) => console.error('[bridge] pair tunnel error:', e.message));
|
|
1115
1355
|
if (settled) { try { t.close(); } catch { /* already gone */ } return; }
|
|
1116
1356
|
done({ url: t.url, kind: 'localtunnel', close: () => { try { t.close(); } catch { /* already gone */ } }, onClose: (cb) => t.on('close', cb) });
|
|
1117
1357
|
})
|
|
@@ -1212,10 +1452,19 @@ async function main() {
|
|
|
1212
1452
|
console.log(' Press Ctrl-C to stop.');
|
|
1213
1453
|
console.log('━'.repeat(68));
|
|
1214
1454
|
|
|
1455
|
+
// Revive this folder's last preview in the background, so a "Preview
|
|
1456
|
+
// Build" pill already sitting in the phone's chat resolves again right
|
|
1457
|
+
// after a restart (the app asks GET /preview for the fresh URL).
|
|
1458
|
+
if (loadPreviewCmd()) revivePreview();
|
|
1459
|
+
|
|
1215
1460
|
let shuttingDown = false;
|
|
1216
1461
|
const shutdown = (signal) => {
|
|
1217
1462
|
shuttingDown = true;
|
|
1218
1463
|
console.log(`\n Received ${signal} — closing tunnel…`);
|
|
1464
|
+
// The agent runs detached in its own process group, so the terminal's
|
|
1465
|
+
// Ctrl-C does NOT reach it — stop it explicitly or it keeps editing
|
|
1466
|
+
// files with no bridge attached.
|
|
1467
|
+
try { activeAgentKill && activeAgentKill('SIGTERM'); } catch { /* already gone */ }
|
|
1219
1468
|
killPreview();
|
|
1220
1469
|
try {
|
|
1221
1470
|
tunnel.close();
|
|
@@ -1227,6 +1476,16 @@ async function main() {
|
|
|
1227
1476
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1228
1477
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1229
1478
|
|
|
1479
|
+
// Crash safety net: without this, an unexpected throw leaves the dev
|
|
1480
|
+
// server, both cloudflared tunnels, and a detached agent running headless.
|
|
1481
|
+
process.on('uncaughtException', (err) => {
|
|
1482
|
+
console.error('\n ✗ Unexpected error:', err && err.stack ? err.stack : err);
|
|
1483
|
+
try { activeAgentKill && activeAgentKill('SIGTERM'); } catch { /* gone */ }
|
|
1484
|
+
killPreview();
|
|
1485
|
+
try { tunnel.close(); } catch { /* closed */ }
|
|
1486
|
+
process.exit(1);
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1230
1489
|
// Detect an unexpected tunnel drop (but stay quiet during our own shutdown,
|
|
1231
1490
|
// since closing the tunnel also fires this).
|
|
1232
1491
|
tunnel.onClose(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "esque-bridge",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.10",
|
|
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"
|