esque-bridge 0.3.0 → 0.5.0

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 +185 -14
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  * through the configured agent adapter, returning stdout
14
14
  *
15
15
  * npx esque-bridge # default: claude
16
+ * npx esque-bridge --agent codex # OpenAI Codex (ChatGPT) on this repo
16
17
  * npx esque-bridge --agent aider # Aider against the current repo
17
18
  * npx esque-bridge --agent custom --cmd 'mycli --prompt {prompt}'
18
19
  */
@@ -36,13 +37,15 @@ if (argv.help || argv.h) {
36
37
  Esque Bridge — pair your phone with a local coding-agent CLI.
37
38
 
38
39
  USAGE
39
- esque-bridge [--agent claude|aider|custom] [--port 3030] [--workdir .]
40
+ esque-bridge [--agent claude|codex|aider|custom] [--port 3030] [--workdir .]
40
41
  [--cmd 'tool --prompt {prompt}'] [--bin <binary>]
41
42
  [--timeout 300000]
42
43
 
43
44
  AGENT ADAPTERS
44
45
  claude (default) — uses Claude Code CLI (\`claude --print --output-format json\`).
45
46
  Persists session ids so each conversation continues via --resume.
47
+ codex — uses OpenAI Codex CLI (\`codex exec\`, headless, auto-approve).
48
+ Drive ChatGPT's coding agent with your ChatGPT plan or API key.
46
49
  aider — uses Aider CLI (\`aider --message ... --yes-always --no-stream\`).
47
50
  Conversation continuity is handled by aider's own .aider.chat.history.md.
48
51
  custom — runs an arbitrary command. Pass --cmd 'tool --prompt {prompt}'
@@ -54,9 +57,12 @@ OPTIONS
54
57
  --bin Override the agent's default binary
55
58
  --timeout Max ms per prompt before SIGTERM (default: 300000 = 5 min)
56
59
  --subdomain Request a stable localtunnel subdomain (optional)
60
+ --rotate Generate a fresh pair secret (invalidates existing pairings)
61
+ --yes, -y Skip the "edit files in <dir>?" confirmation prompt
57
62
 
58
63
  PREREQS
59
64
  Claude: npm install -g @anthropic-ai/claude-code && claude /login
65
+ Codex: npm install -g @openai/codex && codex login
60
66
  Aider: python -m pip install aider-chat
61
67
 
62
68
  OUTPUT
@@ -79,8 +85,35 @@ const AGENT_TYPE = String(argv.agent || process.env.ESQUE_AGENT || 'claude').toL
79
85
  const CUSTOM_CMD = argv.cmd || process.env.ESQUE_CMD || null;
80
86
  const BIN_OVERRIDE = argv.bin || null;
81
87
  const LT_SUBDOMAIN = argv.subdomain || process.env.LT_SUBDOMAIN || undefined;
82
- const PAIRING_SECRET = crypto.randomBytes(16).toString('hex');
83
88
  const SESSIONS_FILE = path.join(os.homedir(), '.esque-bridge-sessions.json');
89
+ const SECRET_FILE = path.join(os.homedir(), '.esque-bridge-secret');
90
+
91
+ // Pairing secret. Persisted across restarts so a routine bridge restart
92
+ // doesn't silently invalidate the phone's pairing (the #1 real-world
93
+ // breakage). Pass `--rotate` to force a fresh secret (e.g. if it leaked).
94
+ // Stored 0600 in the user's home dir; a malformed/empty file is regenerated.
95
+ function resolvePairingSecret() {
96
+ const gen = () => crypto.randomBytes(16).toString('hex');
97
+ if (!argv.rotate) {
98
+ try {
99
+ const existing = fs.readFileSync(SECRET_FILE, 'utf8').trim();
100
+ if (/^[a-f0-9]{32}$/.test(existing)) return existing;
101
+ } catch {
102
+ /* missing/unreadable — fall through and create one */
103
+ }
104
+ }
105
+ const secret = gen();
106
+ try {
107
+ fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
108
+ } catch (err) {
109
+ console.warn(
110
+ `[esque-bridge] could not persist pair secret to ${SECRET_FILE} ` +
111
+ `(${err.message}); it will rotate on next restart.`,
112
+ );
113
+ }
114
+ return secret;
115
+ }
116
+ const PAIRING_SECRET = resolvePairingSecret();
84
117
 
85
118
  function parseArgs(args) {
86
119
  const out = {};
@@ -172,6 +205,37 @@ const ADAPTERS = {
172
205
  },
173
206
  },
174
207
 
208
+ codex: {
209
+ label: 'Codex',
210
+ defaultBin: 'codex',
211
+ install:
212
+ 'npm install -g @openai/codex, then `codex login` (ChatGPT plan or API key).',
213
+ // `exec` is Codex's non-interactive mode. The bypass flag is Codex's
214
+ // analogue of Claude's --dangerously-skip-permissions: in headless mode
215
+ // there's no human to approve file writes / shell commands (incl. the
216
+ // network installs a fresh scaffold needs), so without it the agent
217
+ // looks busy but can't touch the disk. Access is already gated by the
218
+ // pairing secret + the startup workdir confirmation. --skip-git-repo-check
219
+ // lets it run in a brand-new (not-yet-git) project dir for `fresh` builds.
220
+ buildArgs(_prompt, _prevSessionId) {
221
+ return [
222
+ 'exec',
223
+ '--dangerously-bypass-approvals-and-sandbox',
224
+ '--skip-git-repo-check',
225
+ ];
226
+ },
227
+ parseOutput(stdout) {
228
+ // `codex exec` streams its run to stdout and logs to stderr; the trimmed
229
+ // stdout is the agent's reply. (Codex has no stable resume-by-id we rely
230
+ // on here, so each turn is self-contained — Esque re-sends repo context.)
231
+ return {
232
+ text: stdout.trim() || '(codex returned no output)',
233
+ cliSessionId: null,
234
+ isError: false,
235
+ };
236
+ },
237
+ },
238
+
175
239
  aider: {
176
240
  label: 'Aider',
177
241
  defaultBin: 'aider',
@@ -267,15 +331,22 @@ function runAgent(prompt, esqueSessionId) {
267
331
  usesStdin = !argv.some((a) => a.includes(prompt));
268
332
  }
269
333
 
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.
270
337
  const child = spawn(bin, argv, {
271
338
  cwd: WORKDIR,
272
339
  env: process.env,
273
340
  stdio: ['pipe', 'pipe', 'pipe'],
341
+ detached: true,
274
342
  });
275
343
 
344
+ const MAX_BUF = 16 * 1024 * 1024; // hard cap so a runaway agent can't OOM the bridge
276
345
  let stdout = '';
277
346
  let stderr = '';
347
+ let truncated = false;
278
348
  let settled = false;
349
+ let killEscalation = null;
279
350
  const settle = (fn) => (val) => {
280
351
  if (settled) return;
281
352
  settled = true;
@@ -284,17 +355,50 @@ function runAgent(prompt, esqueSessionId) {
284
355
  const resolveOnce = settle(resolve);
285
356
  const rejectOnce = settle(reject);
286
357
 
358
+ const killTree = (signal) => {
359
+ try {
360
+ process.kill(-child.pid, signal);
361
+ } catch {
362
+ try {
363
+ child.kill(signal);
364
+ } catch {
365
+ /* already gone */
366
+ }
367
+ }
368
+ };
369
+
287
370
  const killTimer = setTimeout(() => {
288
- child.kill('SIGTERM');
371
+ killTree('SIGTERM');
372
+ // Escalate to SIGKILL if the agent ignores SIGTERM.
373
+ killEscalation = setTimeout(() => killTree('SIGKILL'), 5000);
289
374
  rejectOnce(
290
375
  new Error(`${adapter.label} timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
291
376
  );
292
377
  }, TIMEOUT_MS);
378
+ const clearTimers = () => {
379
+ clearTimeout(killTimer);
380
+ if (killEscalation) clearTimeout(killEscalation);
381
+ };
293
382
 
294
- child.stdout.on('data', (d) => (stdout += d));
295
- child.stderr.on('data', (d) => (stderr += d));
383
+ const appendCapped = (buf, d) => {
384
+ if (truncated) return buf;
385
+ const next = buf + d;
386
+ if (next.length > MAX_BUF) {
387
+ truncated = true;
388
+ clearTimers();
389
+ killTree('SIGTERM');
390
+ rejectOnce(
391
+ new Error(`${adapter.label} produced too much output (>16MB) and was stopped.`),
392
+ );
393
+ return next.slice(0, MAX_BUF);
394
+ }
395
+ return next;
396
+ };
397
+
398
+ child.stdout.on('data', (d) => (stdout = appendCapped(stdout, d)));
399
+ child.stderr.on('data', (d) => (stderr = appendCapped(stderr, d)));
296
400
  child.on('error', (err) => {
297
- clearTimeout(killTimer);
401
+ clearTimers();
298
402
  if (err.code === 'ENOENT') {
299
403
  rejectOnce(
300
404
  new Error(`'${bin}' not found in PATH. Install: ${adapter.install}`),
@@ -304,7 +408,8 @@ function runAgent(prompt, esqueSessionId) {
304
408
  rejectOnce(err);
305
409
  });
306
410
  child.on('close', (code) => {
307
- clearTimeout(killTimer);
411
+ clearTimers();
412
+ if (truncated) return;
308
413
  if (code !== 0) {
309
414
  rejectOnce(
310
415
  new Error(
@@ -324,10 +429,15 @@ function runAgent(prompt, esqueSessionId) {
324
429
  }
325
430
  });
326
431
 
327
- if (usesStdin) {
328
- child.stdin.write(prompt);
432
+ // A child that dies at startup makes the stdin write throw EPIPE — an
433
+ // uncaught error that would otherwise crash the whole bridge.
434
+ child.stdin.on('error', () => {});
435
+ try {
436
+ if (usesStdin) child.stdin.write(prompt);
437
+ child.stdin.end();
438
+ } catch {
439
+ /* child already gone — handled by the 'error'/'close' listeners */
329
440
  }
330
- child.stdin.end();
331
441
  });
332
442
  }
333
443
 
@@ -353,10 +463,16 @@ app.get('/', (_req, res) => {
353
463
  });
354
464
  });
355
465
 
356
- // Connection-test probe — no auth.
466
+ // Connection-test probe — no auth required to confirm reachability, but if
467
+ // the phone includes the pair secret we validate it and report back via
468
+ // `paired` so the app can verify the secret BEFORE showing a green
469
+ // "Paired" state. `paired` is true (match), false (wrong/stale secret), or
470
+ // null (no secret sent — older app builds; we can't say either way).
357
471
  app.post('/', (req, res, next) => {
358
472
  if (req.body && req.body._probe === true) {
359
- return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE });
473
+ const provided = req.header('x-esque-pair') || req.body?.pairSecret;
474
+ const paired = provided == null ? null : provided === PAIRING_SECRET;
475
+ return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE, paired });
360
476
  }
361
477
  return next();
362
478
  });
@@ -465,7 +581,10 @@ async function startPreview(cmd, port) {
465
581
  return t.url;
466
582
  }
467
583
 
468
- const PREVIEW_RE = /^[ \t]*ESQUE_PREVIEW:\s*(.+?)\s*@\s*(\d+)[ \t]*$/im;
584
+ // Tolerate leading markdown punctuation / code-fence backticks the agent
585
+ // may wrap the marker in (e.g. `ESQUE_PREVIEW: npm run dev @ 3000`), and an
586
+ // optional trailing backtick — otherwise the whole preview feature no-ops.
587
+ const PREVIEW_RE = /^[ \t>*`-]*ESQUE_PREVIEW:\s*(.+?)\s*@\s*(\d+)\s*`?[ \t]*$/im;
469
588
 
470
589
  // If the agent's reply declares a preview, start it (bridge-owned) and
471
590
  // swap the marker line for the public URL the phone can open.
@@ -508,6 +627,10 @@ function gcJobs() {
508
627
  if (now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
509
628
  }
510
629
  }
630
+ // Evict expired jobs even on an idle bridge (the POST path GCs too, but an
631
+ // idle bridge would otherwise hold finished jobs forever). unref so it
632
+ // never keeps the process alive on its own.
633
+ setInterval(gcJobs, 60_000).unref?.();
511
634
 
512
635
  async function executeHandler(req, res) {
513
636
  const body = req.body || {};
@@ -610,12 +733,60 @@ app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
610
733
 
611
734
  // --- Boot -----------------------------------------------------------------
612
735
 
736
+ // One-line y/N confirmation read from the controlling terminal. Resolves
737
+ // true only on an explicit yes.
738
+ function confirmWorkdir(dir) {
739
+ return new Promise((resolve) => {
740
+ const rl = require('readline').createInterface({
741
+ input: process.stdin,
742
+ output: process.stdout,
743
+ });
744
+ rl.question(
745
+ `\n ⚠ Esque will let the AI agent READ and EDIT files in:\n ${dir}\n` +
746
+ ` (Claude runs with --dangerously-skip-permissions, so it won't ask per-file.)\n` +
747
+ ` Continue? [y/N] `,
748
+ (answer) => {
749
+ rl.close();
750
+ resolve(/^y(es)?$/i.test(String(answer).trim()));
751
+ },
752
+ );
753
+ });
754
+ }
755
+
613
756
  async function main() {
614
757
  if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
615
758
  console.error(`workdir does not exist: ${WORKDIR}`);
616
759
  process.exit(1);
617
760
  }
618
761
 
762
+ // Safety gate: confirm the working directory before exposing it to the
763
+ // agent. Only prompts on an interactive terminal — piped/CI starts (and
764
+ // `--yes`) skip it. This is the guard against accidentally running against
765
+ // $HOME or an unintended repo via `npx esque-bridge` in the wrong place.
766
+ if (process.stdin.isTTY && !argv.yes && !argv.y) {
767
+ const ok = await confirmWorkdir(WORKDIR);
768
+ if (!ok) {
769
+ console.log(
770
+ '\n Aborted. cd into your project first, or pass --workdir <path>.\n',
771
+ );
772
+ process.exit(0);
773
+ }
774
+ }
775
+
776
+ // Claude Code refuses to run with --dangerously-skip-permissions as root,
777
+ // which would make every prompt fail on a healthy-looking pairing. Catch it
778
+ // up front with a plain-language fix instead.
779
+ if (
780
+ AGENT_TYPE === 'claude' &&
781
+ typeof process.getuid === 'function' &&
782
+ process.getuid() === 0
783
+ ) {
784
+ console.error('\n ✗ Esque can\'t drive Claude as the root user.');
785
+ console.error(' Re-run WITHOUT sudo, as your normal user:');
786
+ console.error(' npx esque-bridge\n');
787
+ process.exit(1);
788
+ }
789
+
619
790
  await new Promise((resolve) => app.listen(PORT, resolve));
620
791
 
621
792
  let tunnel;
@@ -645,7 +816,7 @@ async function main() {
645
816
  console.log(` Workdir ${WORKDIR}`);
646
817
  console.log(` Binary ${AGENT_BIN ?? '(custom)'}`);
647
818
  console.log(
648
- ` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (rotates on restart don't share)`,
819
+ ` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (persists across restarts; --rotate to reset)`,
649
820
  );
650
821
  console.log('');
651
822
  console.log(' Press Ctrl-C to stop.');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
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.",
5
5
  "bin": {
6
6
  "esque-bridge": "index.js"