esque-bridge 0.6.8 → 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.
Files changed (2) hide show
  1. package/index.js +237 -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,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
- 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 {}
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(/\[[0-9;]*m/g, '');
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
- const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
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(() => { try { proc.kill('SIGKILL'); } catch { /* already gone */ } resolve({ code: -1, out }); }, timeoutMs);
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
- let isNode = false, hasModules = false, isExpo = false;
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
- 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;
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}) — preview may fail`);
871
+ console.error(`[preview] npm install failed (exit ${code})`);
765
872
  break;
766
873
  }
767
- if (!ok) return false;
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
- await ensureDeps();
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
- 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 };
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
- if (preview) preview.output = (preview.output + d).slice(-16000);
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 (!up) { console.log('[preview] dev server never opened the port'); return null; }
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 (!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;
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) preview.buildError = buildError;
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 build failed the web bundle didn't compile:\n\n` +
990
+ `⚠️ Preview failed to build:\n\n` +
847
991
  `${result.buildError}\n\n` +
848
- `Fix that and I'll preview again. (Dev server is still running locally on port ${port}.)`;
992
+ `Fix that and I'll preview again.`;
849
993
  } else {
850
994
  replacement = `🔗 Live preview: ${result.url}`;
851
995
  }
852
- return { text: text.replace(PREVIEW_RE, replacement), buildError };
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 runAgent(prompt, sessionId);
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 runAgent(fixPrompt, sessionId); } catch (e) { console.error('[preview] auto-fix run failed:', e.message); }
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
- if (now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
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
- runAgentWithPreview(prompt, esqueSessionId)
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 runAgentWithPreview(prompt, esqueSessionId);
1181
+ const result = await enqueueRun(prompt, esqueSessionId);
1013
1182
  finish({
1014
1183
  text: result.text,
1015
1184
  status: result.isError ? 'blocked' : 'finished',
@@ -1112,6 +1281,9 @@ function openPairTunnel(port) {
1112
1281
  localtunnel({ port, subdomain: LT_SUBDOMAIN })
1113
1282
  .then((t) => {
1114
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));
1115
1287
  if (settled) { try { t.close(); } catch { /* already gone */ } return; }
1116
1288
  done({ url: t.url, kind: 'localtunnel', close: () => { try { t.close(); } catch { /* already gone */ } }, onClose: (cb) => t.on('close', cb) });
1117
1289
  })
@@ -1216,6 +1388,10 @@ async function main() {
1216
1388
  const shutdown = (signal) => {
1217
1389
  shuttingDown = true;
1218
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 */ }
1219
1395
  killPreview();
1220
1396
  try {
1221
1397
  tunnel.close();
@@ -1227,6 +1403,16 @@ async function main() {
1227
1403
  process.on('SIGINT', () => shutdown('SIGINT'));
1228
1404
  process.on('SIGTERM', () => shutdown('SIGTERM'));
1229
1405
 
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
+
1230
1416
  // Detect an unexpected tunnel drop (but stay quiet during our own shutdown,
1231
1417
  // since closing the tunnel also fires this).
1232
1418
  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.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"