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.
- package/index.js +185 -14
- 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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}… (
|
|
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
|
+
"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"
|