esque-bridge 0.5.0 → 0.6.1

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 +73 -8
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -27,6 +27,10 @@ const fs = require('fs');
27
27
  const path = require('path');
28
28
  const os = require('os');
29
29
 
30
+ // Windows has no POSIX process groups or `.cmd`-aware spawn, so several
31
+ // process-management paths below branch on this.
32
+ const isWindows = process.platform === 'win32';
33
+
30
34
  // --- Config ---------------------------------------------------------------
31
35
 
32
36
  const argv = parseArgs(process.argv.slice(2));
@@ -331,14 +335,19 @@ function runAgent(prompt, esqueSessionId) {
331
335
  usesStdin = !argv.some((a) => a.includes(prompt));
332
336
  }
333
337
 
334
- // detached its own process group so we can kill the WHOLE tree (the
335
- // agent can spawn its own subprocesses) on timeout instead of orphaning
336
- // zombies.
338
+ // POSIX: detach into its own process group so we can kill the WHOLE tree
339
+ // (the agent spawns subprocesses) on timeout instead of orphaning zombies.
340
+ // Windows: no detach (it would pop a console window) — we kill the tree
341
+ // via `taskkill /T` instead; and `shell: true` so spawn can resolve the
342
+ // `.cmd` shims npm installs global bins as (claude.cmd / codex.cmd). Our
343
+ // built-in adapters pass fixed flag args (the prompt rides via stdin), so
344
+ // there's no shell-injection surface here.
337
345
  const child = spawn(bin, argv, {
338
346
  cwd: WORKDIR,
339
347
  env: process.env,
340
348
  stdio: ['pipe', 'pipe', 'pipe'],
341
- detached: true,
349
+ detached: !isWindows,
350
+ shell: isWindows,
342
351
  });
343
352
 
344
353
  const MAX_BUF = 16 * 1024 * 1024; // hard cap so a runaway agent can't OOM the bridge
@@ -356,6 +365,22 @@ function runAgent(prompt, esqueSessionId) {
356
365
  const rejectOnce = settle(reject);
357
366
 
358
367
  const killTree = (signal) => {
368
+ if (isWindows) {
369
+ // No process groups on Windows; force-kill the whole tree by PID.
370
+ // Signals don't map, so SIGTERM/SIGKILL both become a /F force-kill.
371
+ try {
372
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
373
+ stdio: 'ignore',
374
+ });
375
+ } catch {
376
+ try {
377
+ child.kill();
378
+ } catch {
379
+ /* already gone */
380
+ }
381
+ }
382
+ return;
383
+ }
359
384
  try {
360
385
  process.kill(-child.pid, signal);
361
386
  } catch {
@@ -753,6 +778,43 @@ function confirmWorkdir(dir) {
753
778
  });
754
779
  }
755
780
 
781
+ // Bind the local HTTP server to the first free port at/after `startPort`.
782
+ // EADDRINUSE on the default port almost always means a previous esque-bridge
783
+ // is still running (or another app grabbed 3030). Rather than crash with a raw
784
+ // Node stack trace, we step to the next port and tell the user. The pairing
785
+ // URL travels over the tunnel, so the exact local port doesn't matter to the
786
+ // app. Returns { server, port } for the port we actually bound.
787
+ function listenOnFreePort(app, startPort, maxTries = 25) {
788
+ return new Promise((resolve, reject) => {
789
+ let attempts = 0;
790
+ const attempt = (port) => {
791
+ const server = app.listen(port);
792
+ const onError = (err) => {
793
+ server.removeListener('listening', onListening);
794
+ if (err && err.code === 'EADDRINUSE' && attempts < maxTries) {
795
+ if (attempts === 0) {
796
+ console.error(
797
+ `\n Port ${startPort} is busy — another Esque bridge may already be running.`,
798
+ );
799
+ console.error(' Stepping to the next free port…');
800
+ }
801
+ attempts += 1;
802
+ attempt(port + 1);
803
+ } else {
804
+ reject(err);
805
+ }
806
+ };
807
+ const onListening = () => {
808
+ server.removeListener('error', onError);
809
+ resolve({ server, port });
810
+ };
811
+ server.once('error', onError);
812
+ server.once('listening', onListening);
813
+ };
814
+ attempt(startPort);
815
+ });
816
+ }
817
+
756
818
  async function main() {
757
819
  if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
758
820
  console.error(`workdir does not exist: ${WORKDIR}`);
@@ -787,15 +849,18 @@ async function main() {
787
849
  process.exit(1);
788
850
  }
789
851
 
790
- await new Promise((resolve) => app.listen(PORT, resolve));
852
+ const { port: boundPort } = await listenOnFreePort(app, PORT);
853
+ if (boundPort !== PORT) {
854
+ console.log(` ✓ Using port ${boundPort} instead (${PORT} was taken).`);
855
+ }
791
856
 
792
857
  let tunnel;
793
858
  try {
794
- tunnel = await localtunnel({ port: PORT, subdomain: LT_SUBDOMAIN });
859
+ tunnel = await localtunnel({ port: boundPort, subdomain: LT_SUBDOMAIN });
795
860
  } catch (err) {
796
861
  console.error('Failed to open localtunnel:', err.message);
797
862
  console.error('If localtunnel.me is blocked, try cloudflared:');
798
- console.error(` cloudflared tunnel --url http://localhost:${PORT}`);
863
+ console.error(` cloudflared tunnel --url http://localhost:${boundPort}`);
799
864
  process.exit(1);
800
865
  }
801
866
 
@@ -811,7 +876,7 @@ async function main() {
811
876
  qrcode.generate(pairUrl, { small: true });
812
877
  console.log('');
813
878
  console.log(` Agent ${adapter.label} (${AGENT_TYPE})`);
814
- console.log(` Local http://localhost:${PORT}`);
879
+ console.log(` Local http://localhost:${boundPort}`);
815
880
  console.log(` Tunnel ${tunnel.url}`);
816
881
  console.log(` Workdir ${WORKDIR}`);
817
882
  console.log(` Binary ${AGENT_BIN ?? '(custom)'}`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.5.0",
4
- "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Aider, or any custom command) via a tunnel + QR code, so prompts run through your subscription instead of per-token API billing.",
3
+ "version": "0.6.1",
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"
7
7
  },