esque-bridge 0.2.2 → 0.4.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 +267 -14
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -54,6 +54,8 @@ OPTIONS
|
|
|
54
54
|
--bin Override the agent's default binary
|
|
55
55
|
--timeout Max ms per prompt before SIGTERM (default: 300000 = 5 min)
|
|
56
56
|
--subdomain Request a stable localtunnel subdomain (optional)
|
|
57
|
+
--rotate Generate a fresh pair secret (invalidates existing pairings)
|
|
58
|
+
--yes, -y Skip the "edit files in <dir>?" confirmation prompt
|
|
57
59
|
|
|
58
60
|
PREREQS
|
|
59
61
|
Claude: npm install -g @anthropic-ai/claude-code && claude /login
|
|
@@ -79,8 +81,35 @@ const AGENT_TYPE = String(argv.agent || process.env.ESQUE_AGENT || 'claude').toL
|
|
|
79
81
|
const CUSTOM_CMD = argv.cmd || process.env.ESQUE_CMD || null;
|
|
80
82
|
const BIN_OVERRIDE = argv.bin || null;
|
|
81
83
|
const LT_SUBDOMAIN = argv.subdomain || process.env.LT_SUBDOMAIN || undefined;
|
|
82
|
-
const PAIRING_SECRET = crypto.randomBytes(16).toString('hex');
|
|
83
84
|
const SESSIONS_FILE = path.join(os.homedir(), '.esque-bridge-sessions.json');
|
|
85
|
+
const SECRET_FILE = path.join(os.homedir(), '.esque-bridge-secret');
|
|
86
|
+
|
|
87
|
+
// Pairing secret. Persisted across restarts so a routine bridge restart
|
|
88
|
+
// doesn't silently invalidate the phone's pairing (the #1 real-world
|
|
89
|
+
// breakage). Pass `--rotate` to force a fresh secret (e.g. if it leaked).
|
|
90
|
+
// Stored 0600 in the user's home dir; a malformed/empty file is regenerated.
|
|
91
|
+
function resolvePairingSecret() {
|
|
92
|
+
const gen = () => crypto.randomBytes(16).toString('hex');
|
|
93
|
+
if (!argv.rotate) {
|
|
94
|
+
try {
|
|
95
|
+
const existing = fs.readFileSync(SECRET_FILE, 'utf8').trim();
|
|
96
|
+
if (/^[a-f0-9]{32}$/.test(existing)) return existing;
|
|
97
|
+
} catch {
|
|
98
|
+
/* missing/unreadable — fall through and create one */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const secret = gen();
|
|
102
|
+
try {
|
|
103
|
+
fs.writeFileSync(SECRET_FILE, secret, { mode: 0o600 });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.warn(
|
|
106
|
+
`[esque-bridge] could not persist pair secret to ${SECRET_FILE} ` +
|
|
107
|
+
`(${err.message}); it will rotate on next restart.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return secret;
|
|
111
|
+
}
|
|
112
|
+
const PAIRING_SECRET = resolvePairingSecret();
|
|
84
113
|
|
|
85
114
|
function parseArgs(args) {
|
|
86
115
|
const out = {};
|
|
@@ -267,15 +296,22 @@ function runAgent(prompt, esqueSessionId) {
|
|
|
267
296
|
usesStdin = !argv.some((a) => a.includes(prompt));
|
|
268
297
|
}
|
|
269
298
|
|
|
299
|
+
// detached → its own process group so we can kill the WHOLE tree (the
|
|
300
|
+
// agent can spawn its own subprocesses) on timeout instead of orphaning
|
|
301
|
+
// zombies.
|
|
270
302
|
const child = spawn(bin, argv, {
|
|
271
303
|
cwd: WORKDIR,
|
|
272
304
|
env: process.env,
|
|
273
305
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
306
|
+
detached: true,
|
|
274
307
|
});
|
|
275
308
|
|
|
309
|
+
const MAX_BUF = 16 * 1024 * 1024; // hard cap so a runaway agent can't OOM the bridge
|
|
276
310
|
let stdout = '';
|
|
277
311
|
let stderr = '';
|
|
312
|
+
let truncated = false;
|
|
278
313
|
let settled = false;
|
|
314
|
+
let killEscalation = null;
|
|
279
315
|
const settle = (fn) => (val) => {
|
|
280
316
|
if (settled) return;
|
|
281
317
|
settled = true;
|
|
@@ -284,17 +320,50 @@ function runAgent(prompt, esqueSessionId) {
|
|
|
284
320
|
const resolveOnce = settle(resolve);
|
|
285
321
|
const rejectOnce = settle(reject);
|
|
286
322
|
|
|
323
|
+
const killTree = (signal) => {
|
|
324
|
+
try {
|
|
325
|
+
process.kill(-child.pid, signal);
|
|
326
|
+
} catch {
|
|
327
|
+
try {
|
|
328
|
+
child.kill(signal);
|
|
329
|
+
} catch {
|
|
330
|
+
/* already gone */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
287
335
|
const killTimer = setTimeout(() => {
|
|
288
|
-
|
|
336
|
+
killTree('SIGTERM');
|
|
337
|
+
// Escalate to SIGKILL if the agent ignores SIGTERM.
|
|
338
|
+
killEscalation = setTimeout(() => killTree('SIGKILL'), 5000);
|
|
289
339
|
rejectOnce(
|
|
290
340
|
new Error(`${adapter.label} timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
|
|
291
341
|
);
|
|
292
342
|
}, TIMEOUT_MS);
|
|
343
|
+
const clearTimers = () => {
|
|
344
|
+
clearTimeout(killTimer);
|
|
345
|
+
if (killEscalation) clearTimeout(killEscalation);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const appendCapped = (buf, d) => {
|
|
349
|
+
if (truncated) return buf;
|
|
350
|
+
const next = buf + d;
|
|
351
|
+
if (next.length > MAX_BUF) {
|
|
352
|
+
truncated = true;
|
|
353
|
+
clearTimers();
|
|
354
|
+
killTree('SIGTERM');
|
|
355
|
+
rejectOnce(
|
|
356
|
+
new Error(`${adapter.label} produced too much output (>16MB) and was stopped.`),
|
|
357
|
+
);
|
|
358
|
+
return next.slice(0, MAX_BUF);
|
|
359
|
+
}
|
|
360
|
+
return next;
|
|
361
|
+
};
|
|
293
362
|
|
|
294
|
-
child.stdout.on('data', (d) => (stdout
|
|
295
|
-
child.stderr.on('data', (d) => (stderr
|
|
363
|
+
child.stdout.on('data', (d) => (stdout = appendCapped(stdout, d)));
|
|
364
|
+
child.stderr.on('data', (d) => (stderr = appendCapped(stderr, d)));
|
|
296
365
|
child.on('error', (err) => {
|
|
297
|
-
|
|
366
|
+
clearTimers();
|
|
298
367
|
if (err.code === 'ENOENT') {
|
|
299
368
|
rejectOnce(
|
|
300
369
|
new Error(`'${bin}' not found in PATH. Install: ${adapter.install}`),
|
|
@@ -304,7 +373,8 @@ function runAgent(prompt, esqueSessionId) {
|
|
|
304
373
|
rejectOnce(err);
|
|
305
374
|
});
|
|
306
375
|
child.on('close', (code) => {
|
|
307
|
-
|
|
376
|
+
clearTimers();
|
|
377
|
+
if (truncated) return;
|
|
308
378
|
if (code !== 0) {
|
|
309
379
|
rejectOnce(
|
|
310
380
|
new Error(
|
|
@@ -324,10 +394,15 @@ function runAgent(prompt, esqueSessionId) {
|
|
|
324
394
|
}
|
|
325
395
|
});
|
|
326
396
|
|
|
327
|
-
|
|
328
|
-
|
|
397
|
+
// A child that dies at startup makes the stdin write throw EPIPE — an
|
|
398
|
+
// uncaught error that would otherwise crash the whole bridge.
|
|
399
|
+
child.stdin.on('error', () => {});
|
|
400
|
+
try {
|
|
401
|
+
if (usesStdin) child.stdin.write(prompt);
|
|
402
|
+
child.stdin.end();
|
|
403
|
+
} catch {
|
|
404
|
+
/* child already gone — handled by the 'error'/'close' listeners */
|
|
329
405
|
}
|
|
330
|
-
child.stdin.end();
|
|
331
406
|
});
|
|
332
407
|
}
|
|
333
408
|
|
|
@@ -353,10 +428,16 @@ app.get('/', (_req, res) => {
|
|
|
353
428
|
});
|
|
354
429
|
});
|
|
355
430
|
|
|
356
|
-
// Connection-test probe — no auth
|
|
431
|
+
// Connection-test probe — no auth required to confirm reachability, but if
|
|
432
|
+
// the phone includes the pair secret we validate it and report back via
|
|
433
|
+
// `paired` so the app can verify the secret BEFORE showing a green
|
|
434
|
+
// "Paired" state. `paired` is true (match), false (wrong/stale secret), or
|
|
435
|
+
// null (no secret sent — older app builds; we can't say either way).
|
|
357
436
|
app.post('/', (req, res, next) => {
|
|
358
437
|
if (req.body && req.body._probe === true) {
|
|
359
|
-
|
|
438
|
+
const provided = req.header('x-esque-pair') || req.body?.pairSecret;
|
|
439
|
+
const paired = provided == null ? null : provided === PAIRING_SECRET;
|
|
440
|
+
return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE, paired });
|
|
360
441
|
}
|
|
361
442
|
return next();
|
|
362
443
|
});
|
|
@@ -373,6 +454,125 @@ function requireAuth(req, res, next) {
|
|
|
373
454
|
next();
|
|
374
455
|
}
|
|
375
456
|
|
|
457
|
+
// ── Live preview (bridge-owned) ───────────────────────────────────────
|
|
458
|
+
// The agent declares a preview with a marker line in its reply:
|
|
459
|
+
// ESQUE_PREVIEW: <dev server start command> @ <port>
|
|
460
|
+
// e.g. ESQUE_PREVIEW: npm run dev @ 3000
|
|
461
|
+
//
|
|
462
|
+
// The BRIDGE runs that dev server and tunnels it — NOT the one-shot
|
|
463
|
+
// `claude --print`, which exits the instant its turn ends and would
|
|
464
|
+
// orphan/kill anything it started (that's why the old "agent starts its
|
|
465
|
+
// own tunnel" approach left the preview URL dead by the time you tapped
|
|
466
|
+
// it). cloudflared is preferred over localtunnel because its URL has no
|
|
467
|
+
// "click to continue" interstitial that would block the in-app WebView.
|
|
468
|
+
const net = require('net');
|
|
469
|
+
const { execSync } = require('child_process');
|
|
470
|
+
|
|
471
|
+
let preview = null; // { port, proc, kind, url, tunnel, tunnelProc }
|
|
472
|
+
|
|
473
|
+
function killPreview() {
|
|
474
|
+
if (!preview) return;
|
|
475
|
+
try { preview.proc && preview.proc.kill('SIGTERM'); } catch {}
|
|
476
|
+
try { preview.tunnel && preview.tunnel.close(); } catch {}
|
|
477
|
+
try { preview.tunnelProc && preview.tunnelProc.kill('SIGTERM'); } catch {}
|
|
478
|
+
preview = null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function waitForPort(port, timeoutMs) {
|
|
482
|
+
const start = Date.now();
|
|
483
|
+
return new Promise((resolve) => {
|
|
484
|
+
const tick = () => {
|
|
485
|
+
const s = net.connect(port, '127.0.0.1');
|
|
486
|
+
s.on('connect', () => { s.destroy(); resolve(true); });
|
|
487
|
+
s.on('error', () => {
|
|
488
|
+
s.destroy();
|
|
489
|
+
if (Date.now() - start > timeoutMs) resolve(false);
|
|
490
|
+
else setTimeout(tick, 600);
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
tick();
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function hasCloudflared() {
|
|
498
|
+
try { execSync('which cloudflared', { stdio: 'ignore' }); return true; }
|
|
499
|
+
catch { return false; }
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Open a public tunnel to a local port. Prefers cloudflared (clean URL,
|
|
503
|
+
// no interstitial), falls back to localtunnel. Resolves the tunnel handle.
|
|
504
|
+
function openPreviewTunnel(port) {
|
|
505
|
+
return new Promise(async (resolve) => {
|
|
506
|
+
if (hasCloudflared()) {
|
|
507
|
+
const proc = spawn('cloudflared', ['tunnel', '--url', `http://localhost:${port}`], {
|
|
508
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
509
|
+
});
|
|
510
|
+
let done = false;
|
|
511
|
+
const scan = (d) => {
|
|
512
|
+
const m = String(d).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
513
|
+
if (m && !done) { done = true; resolve({ url: m[0], kind: 'cloudflared', tunnelProc: proc }); }
|
|
514
|
+
};
|
|
515
|
+
proc.stdout.on('data', scan);
|
|
516
|
+
proc.stderr.on('data', scan);
|
|
517
|
+
setTimeout(() => { if (!done) { done = true; try { proc.kill(); } catch {} resolve(null); } }, 25000);
|
|
518
|
+
} else {
|
|
519
|
+
try {
|
|
520
|
+
const tunnel = await localtunnel({ port });
|
|
521
|
+
resolve({ url: tunnel.url, kind: 'localtunnel', tunnel });
|
|
522
|
+
} catch { resolve(null); }
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function startPreview(cmd, port) {
|
|
528
|
+
killPreview();
|
|
529
|
+
console.log(`[preview] starting: ${cmd} (port ${port})`);
|
|
530
|
+
const proc = spawn('sh', ['-c', cmd], { cwd: WORKDIR, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
531
|
+
proc.stdout.on('data', (d) => process.stdout.write(`[preview] ${d}`));
|
|
532
|
+
proc.stderr.on('data', (d) => process.stdout.write(`[preview] ${d}`));
|
|
533
|
+
proc.on('exit', (code) => console.log(`[preview] dev server exited (${code})`));
|
|
534
|
+
preview = { port, proc, kind: null, url: null, tunnel: null, tunnelProc: null };
|
|
535
|
+
|
|
536
|
+
const up = await waitForPort(port, 60000);
|
|
537
|
+
if (!up) { console.log('[preview] dev server never opened the port'); return null; }
|
|
538
|
+
|
|
539
|
+
const t = await openPreviewTunnel(port);
|
|
540
|
+
if (!t) { console.log('[preview] could not open a public tunnel'); return null; }
|
|
541
|
+
preview.url = t.url;
|
|
542
|
+
preview.kind = t.kind;
|
|
543
|
+
preview.tunnel = t.tunnel || null;
|
|
544
|
+
preview.tunnelProc = t.tunnelProc || null;
|
|
545
|
+
console.log(`[preview] live at ${t.url} (${t.kind})`);
|
|
546
|
+
return t.url;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Tolerate leading markdown punctuation / code-fence backticks the agent
|
|
550
|
+
// may wrap the marker in (e.g. `ESQUE_PREVIEW: npm run dev @ 3000`), and an
|
|
551
|
+
// optional trailing backtick — otherwise the whole preview feature no-ops.
|
|
552
|
+
const PREVIEW_RE = /^[ \t>*`-]*ESQUE_PREVIEW:\s*(.+?)\s*@\s*(\d+)\s*`?[ \t]*$/im;
|
|
553
|
+
|
|
554
|
+
// If the agent's reply declares a preview, start it (bridge-owned) and
|
|
555
|
+
// swap the marker line for the public URL the phone can open.
|
|
556
|
+
async function applyPreview(text) {
|
|
557
|
+
if (!text) return text;
|
|
558
|
+
const m = text.match(PREVIEW_RE);
|
|
559
|
+
if (!m) return text;
|
|
560
|
+
const cmd = m[1].trim();
|
|
561
|
+
const port = Number(m[2]);
|
|
562
|
+
let url = null;
|
|
563
|
+
try { url = await startPreview(cmd, port); } catch (e) { console.error('[preview] error:', e.message); }
|
|
564
|
+
const replacement = url
|
|
565
|
+
? `🔗 Live preview: ${url}`
|
|
566
|
+
: `(Couldn't auto-start the preview — run \`${cmd}\` in ${WORKDIR} yourself.)`;
|
|
567
|
+
return text.replace(PREVIEW_RE, replacement);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function runAgentWithPreview(prompt, sessionId) {
|
|
571
|
+
const result = await runAgent(prompt, sessionId);
|
|
572
|
+
if (result && result.text) result.text = await applyPreview(result.text);
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
|
|
376
576
|
// ── Async job store ───────────────────────────────────────────────────
|
|
377
577
|
// A prompt can run for minutes (e.g. a full project scaffold). Holding one
|
|
378
578
|
// HTTP connection open that long is fragile over tunnels and times the
|
|
@@ -392,6 +592,10 @@ function gcJobs() {
|
|
|
392
592
|
if (now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
|
|
393
593
|
}
|
|
394
594
|
}
|
|
595
|
+
// Evict expired jobs even on an idle bridge (the POST path GCs too, but an
|
|
596
|
+
// idle bridge would otherwise hold finished jobs forever). unref so it
|
|
597
|
+
// never keeps the process alive on its own.
|
|
598
|
+
setInterval(gcJobs, 60_000).unref?.();
|
|
395
599
|
|
|
396
600
|
async function executeHandler(req, res) {
|
|
397
601
|
const body = req.body || {};
|
|
@@ -417,7 +621,7 @@ async function executeHandler(req, res) {
|
|
|
417
621
|
const jobId = newJobId();
|
|
418
622
|
jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
|
|
419
623
|
res.json({ jobId, status: 'working' });
|
|
420
|
-
|
|
624
|
+
runAgentWithPreview(prompt, esqueSessionId)
|
|
421
625
|
.then((result) => {
|
|
422
626
|
jobs.set(jobId, {
|
|
423
627
|
status: result.isError ? 'blocked' : 'finished',
|
|
@@ -463,7 +667,7 @@ async function executeHandler(req, res) {
|
|
|
463
667
|
};
|
|
464
668
|
|
|
465
669
|
try {
|
|
466
|
-
const result = await
|
|
670
|
+
const result = await runAgentWithPreview(prompt, esqueSessionId);
|
|
467
671
|
finish({
|
|
468
672
|
text: result.text,
|
|
469
673
|
status: result.isError ? 'blocked' : 'finished',
|
|
@@ -494,12 +698,60 @@ app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
|
|
|
494
698
|
|
|
495
699
|
// --- Boot -----------------------------------------------------------------
|
|
496
700
|
|
|
701
|
+
// One-line y/N confirmation read from the controlling terminal. Resolves
|
|
702
|
+
// true only on an explicit yes.
|
|
703
|
+
function confirmWorkdir(dir) {
|
|
704
|
+
return new Promise((resolve) => {
|
|
705
|
+
const rl = require('readline').createInterface({
|
|
706
|
+
input: process.stdin,
|
|
707
|
+
output: process.stdout,
|
|
708
|
+
});
|
|
709
|
+
rl.question(
|
|
710
|
+
`\n ⚠ Esque will let the AI agent READ and EDIT files in:\n ${dir}\n` +
|
|
711
|
+
` (Claude runs with --dangerously-skip-permissions, so it won't ask per-file.)\n` +
|
|
712
|
+
` Continue? [y/N] `,
|
|
713
|
+
(answer) => {
|
|
714
|
+
rl.close();
|
|
715
|
+
resolve(/^y(es)?$/i.test(String(answer).trim()));
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
497
721
|
async function main() {
|
|
498
722
|
if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
|
|
499
723
|
console.error(`workdir does not exist: ${WORKDIR}`);
|
|
500
724
|
process.exit(1);
|
|
501
725
|
}
|
|
502
726
|
|
|
727
|
+
// Safety gate: confirm the working directory before exposing it to the
|
|
728
|
+
// agent. Only prompts on an interactive terminal — piped/CI starts (and
|
|
729
|
+
// `--yes`) skip it. This is the guard against accidentally running against
|
|
730
|
+
// $HOME or an unintended repo via `npx esque-bridge` in the wrong place.
|
|
731
|
+
if (process.stdin.isTTY && !argv.yes && !argv.y) {
|
|
732
|
+
const ok = await confirmWorkdir(WORKDIR);
|
|
733
|
+
if (!ok) {
|
|
734
|
+
console.log(
|
|
735
|
+
'\n Aborted. cd into your project first, or pass --workdir <path>.\n',
|
|
736
|
+
);
|
|
737
|
+
process.exit(0);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Claude Code refuses to run with --dangerously-skip-permissions as root,
|
|
742
|
+
// which would make every prompt fail on a healthy-looking pairing. Catch it
|
|
743
|
+
// up front with a plain-language fix instead.
|
|
744
|
+
if (
|
|
745
|
+
AGENT_TYPE === 'claude' &&
|
|
746
|
+
typeof process.getuid === 'function' &&
|
|
747
|
+
process.getuid() === 0
|
|
748
|
+
) {
|
|
749
|
+
console.error('\n ✗ Esque can\'t drive Claude as the root user.');
|
|
750
|
+
console.error(' Re-run WITHOUT sudo, as your normal user:');
|
|
751
|
+
console.error(' npx esque-bridge\n');
|
|
752
|
+
process.exit(1);
|
|
753
|
+
}
|
|
754
|
+
|
|
503
755
|
await new Promise((resolve) => app.listen(PORT, resolve));
|
|
504
756
|
|
|
505
757
|
let tunnel;
|
|
@@ -529,7 +781,7 @@ async function main() {
|
|
|
529
781
|
console.log(` Workdir ${WORKDIR}`);
|
|
530
782
|
console.log(` Binary ${AGENT_BIN ?? '(custom)'}`);
|
|
531
783
|
console.log(
|
|
532
|
-
` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (
|
|
784
|
+
` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (persists across restarts; --rotate to reset)`,
|
|
533
785
|
);
|
|
534
786
|
console.log('');
|
|
535
787
|
console.log(' Press Ctrl-C to stop.');
|
|
@@ -537,6 +789,7 @@ async function main() {
|
|
|
537
789
|
|
|
538
790
|
const shutdown = (signal) => {
|
|
539
791
|
console.log(`\n Received ${signal} — closing tunnel…`);
|
|
792
|
+
killPreview();
|
|
540
793
|
try {
|
|
541
794
|
tunnel.close();
|
|
542
795
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "esque-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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"
|