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.
Files changed (2) hide show
  1. package/index.js +310 -51
  2. 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
- buildArgs(_prompt) {
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', '-', // read message from stdin (we'll pipe it)
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 always rides via stdin (avoids
329
- // ARG_MAX on long blueprint payloads).
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 = true;
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 { workdir: WORKDIR, workdirName: path.basename(WORKDIR), empty, git, entries };
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
- try { preview.proc && preview.proc.kill('SIGTERM'); } catch {}
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(/\[[0-9;]*m/g, '');
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
- const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
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(() => { try { proc.kill('SIGKILL'); } catch { /* already gone */ } resolve({ code: -1, out }); }, timeoutMs);
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
- let isNode = false, hasModules = false, isExpo = false;
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
- const pkgPath = path.join(WORKDIR, 'package.json');
742
- isNode = fs.existsSync(pkgPath);
743
- hasModules = fs.existsSync(path.join(WORKDIR, 'node_modules'));
744
- if (isNode) {
745
- const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
746
- isExpo = !!(pkg.dependencies && pkg.dependencies.expo);
747
- }
748
- } catch { isNode = false; }
749
- if (!isNode || hasModules) return true;
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}) — preview may fail`);
897
+ console.error(`[preview] npm install failed (exit ${code})`);
765
898
  break;
766
899
  }
767
- if (!ok) return false;
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
- await ensureDeps();
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
- const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
788
- preview = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null, output: '', buildError: null };
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
- if (preview) preview.output = (preview.output + d).slice(-16000);
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 (!up) { console.log('[preview] dev server never opened the port'); return null; }
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 (!t) { console.log('[preview] could not open a public tunnel'); return null; }
803
- preview.url = t.url;
804
- preview.kind = t.kind;
805
- preview.tunnel = t.tunnel || null;
806
- preview.tunnelProc = t.tunnelProc || null;
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) preview.buildError = buildError;
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 build failed the web bundle didn't compile:\n\n` +
1019
+ `⚠️ Preview failed to build:\n\n` +
847
1020
  `${result.buildError}\n\n` +
848
- `Fix that and I'll preview again. (Dev server is still running locally on port ${port}.)`;
1021
+ `Fix that and I'll preview again.`;
849
1022
  } else {
850
1023
  replacement = `🔗 Live preview: ${result.url}`;
851
1024
  }
852
- return { text: text.replace(PREVIEW_RE, replacement), buildError };
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 runAgent(prompt, sessionId);
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 runAgent(fixPrompt, sessionId); } catch (e) { console.error('[preview] auto-fix run failed:', e.message); }
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
- if (now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
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
- runAgentWithPreview(prompt, esqueSessionId)
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 runAgentWithPreview(prompt, esqueSessionId);
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.8",
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"