esque-bridge 0.1.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 (3) hide show
  1. package/README.md +83 -0
  2. package/index.js +351 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # esque-bridge
2
+
3
+ Desktop-side receiver for the [Esque Agent](https://esque.app) iOS app. Pairs your phone with the local `claude` CLI so prompts you send from the app run through your **Claude Pro / Max subscription** rather than per-token API billing.
4
+
5
+ ## What it does
6
+
7
+ 1. Boots an Express server on localhost
8
+ 2. Opens a public localtunnel URL to that port
9
+ 3. Prints a QR code in your terminal embedding `esque://pair?url=…&secret=…`
10
+ 4. Accepts `POST /execute { prompt, sessionId }` (pairing-secret-gated)
11
+ 5. Runs each prompt through `claude --print --output-format json`
12
+ 6. Returns Claude's response to the phone, persisting the Claude session id so the next prompt continues the same conversation
13
+
14
+ ## Install
15
+
16
+ You'll need:
17
+
18
+ - **Node.js 18 or newer** — https://nodejs.org
19
+ - **Claude Code CLI** logged into your Pro/Max account:
20
+ ```bash
21
+ npm install -g @anthropic-ai/claude-code
22
+ claude /login
23
+ ```
24
+
25
+ Then either run via `npx` (no install) or globally:
26
+
27
+ ```bash
28
+ npx esque-bridge # zero-install
29
+ # — or —
30
+ npm install -g esque-bridge
31
+ esque-bridge
32
+ ```
33
+
34
+ ## Run
35
+
36
+ ```bash
37
+ # from the directory you want Claude to edit:
38
+ esque-bridge
39
+
40
+ # point at a different repo without cd:
41
+ esque-bridge --workdir ~/my-project
42
+
43
+ # pick a non-default port:
44
+ esque-bridge --port 4000
45
+ ```
46
+
47
+ The terminal will print a QR code. Open the **Esque Agent** iOS app, tap **Pair Bridge**, and scan the code. That's it.
48
+
49
+ ## Flags
50
+
51
+ | Flag | Default | Purpose |
52
+ | ------------- | ------------ | ----------------------------------------- |
53
+ | `--port` | `3030` | Local HTTP port |
54
+ | `--workdir` | `$(pwd)` | Directory Claude runs in |
55
+ | `--bin` | `claude` | Path to the Claude CLI binary |
56
+ | `--timeout` | `300000` | Per-prompt timeout (ms) |
57
+ | `--subdomain` | _(auto)_ | Stable localtunnel subdomain (optional) |
58
+
59
+ ## Security notes
60
+
61
+ - The pairing secret is regenerated every time the bridge starts and never persisted. If you stop and restart, you must re-pair the phone. This is deliberate — a leaked tunnel URL alone can't drive your Claude session.
62
+ - The bridge accepts a no-auth `GET /` and `POST / {_probe: true}` for the Esque app's connection-test only. Every other request requires the secret in the `X-Esque-Pair` header.
63
+ - `spawn(claude, [...args])` is used so the prompt body can never inject shell commands.
64
+
65
+ ## Troubleshooting
66
+
67
+ **`'claude' not found in PATH`** — install the CLI: `npm install -g @anthropic-ai/claude-code`, then `claude /login` to authenticate.
68
+
69
+ **`Failed to open localtunnel`** — your network is blocking localtunnel.me. Run cloudflared instead:
70
+ ```bash
71
+ cloudflared tunnel --url http://localhost:3030
72
+ ```
73
+ …and paste that URL into the Esque app manually (skip the QR).
74
+
75
+ **Long prompts time out** — bump `--timeout 600000` (10 min).
76
+
77
+ ## Session continuity
78
+
79
+ Each Esque conversation maps to a Claude session id. The map is persisted to `~/.esque-bridge-sessions.json` so restarting the bridge doesn't lose context. Delete that file to wipe history.
80
+
81
+ ## License
82
+
83
+ MIT
package/index.js ADDED
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * esque-bridge — the desktop-side receiver for the Esque Agent mobile app.
4
+ *
5
+ * Pairs your phone with the local `claude` CLI so prompts sent from the
6
+ * Esque iOS app run through your Claude Pro / Max subscription rather than
7
+ * per-token API billing. The bridge:
8
+ *
9
+ * 1. Boots an Express server on localhost
10
+ * 2. Opens a public localtunnel URL to that port
11
+ * 3. Prints a QR code embedding `esque://pair?url=…&secret=…` in your
12
+ * terminal — scan it from the Esque app to pair
13
+ * 4. Accepts POST /execute { prompt, sessionId } (pairing-secret-gated)
14
+ * and runs the prompt through `claude --print`, returning stdout
15
+ *
16
+ * Run it:
17
+ * npx esque-bridge # zero-config
18
+ * esque-bridge --workdir ~/my-project # point claude at a repo
19
+ */
20
+
21
+ const express = require('express');
22
+ const localtunnel = require('localtunnel');
23
+ const qrcode = require('qrcode-terminal');
24
+ const { spawn } = require('child_process');
25
+ const crypto = require('crypto');
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+
30
+ // --- Config ---------------------------------------------------------------
31
+
32
+ const argv = parseArgs(process.argv.slice(2));
33
+
34
+ if (argv.help || argv.h) {
35
+ console.log(
36
+ `
37
+ Esque Bridge — pair your phone with your local Claude Code.
38
+
39
+ USAGE
40
+ esque-bridge [--port 3030] [--workdir .] [--bin claude] [--timeout 300000]
41
+
42
+ OPTIONS
43
+ --port Local HTTP port to listen on (default: 3030)
44
+ --workdir Working directory for claude (default: current dir)
45
+ --bin Path to claude CLI binary (default: 'claude' on PATH)
46
+ --timeout Max ms per prompt before SIGTERM (default: 300000 = 5 min)
47
+ --subdomain Request a stable localtunnel subdomain (optional)
48
+
49
+ PREREQS
50
+ npm install -g @anthropic-ai/claude-code
51
+ claude /login # one-time OAuth
52
+
53
+ OUTPUT
54
+ Prints a QR code in your terminal. Scan it from the Esque iOS app to
55
+ pair this laptop with your phone.
56
+ `.trim(),
57
+ );
58
+ process.exit(0);
59
+ }
60
+
61
+ const PORT = Number(argv.port || process.env.PORT || 3030);
62
+ const WORKDIR = path.resolve(
63
+ argv.workdir || process.env.CLAUDE_WORKDIR || process.cwd(),
64
+ );
65
+ const TIMEOUT_MS = Number(argv.timeout || 5 * 60 * 1000);
66
+ const CLAUDE_BIN = argv.bin || process.env.CLAUDE_BIN || 'claude';
67
+ const LT_SUBDOMAIN = argv.subdomain || process.env.LT_SUBDOMAIN || undefined;
68
+ const PAIRING_SECRET = crypto.randomBytes(16).toString('hex');
69
+ const SESSIONS_FILE = path.join(os.homedir(), '.esque-bridge-sessions.json');
70
+
71
+ function parseArgs(args) {
72
+ const out = {};
73
+ for (let i = 0; i < args.length; i++) {
74
+ const a = args[i];
75
+ if (!a.startsWith('--') && !a.startsWith('-')) continue;
76
+ const key = a.replace(/^-+/, '');
77
+ const next = args[i + 1];
78
+ if (!next || next.startsWith('-')) {
79
+ out[key] = true;
80
+ } else {
81
+ out[key] = next;
82
+ i++;
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+
88
+ // --- Session persistence -------------------------------------------------
89
+ // Map of {esqueSessionId → claudeSessionId} so each conversation continues
90
+ // in-context across prompts via `claude --resume`. Survives bridge restarts.
91
+
92
+ let sessionMap = {};
93
+ try {
94
+ sessionMap = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
95
+ } catch {
96
+ /* first run */
97
+ }
98
+ function saveSessions() {
99
+ try {
100
+ fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessionMap, null, 2));
101
+ } catch (err) {
102
+ console.warn('[bridge] could not persist session map:', err.message);
103
+ }
104
+ }
105
+
106
+ // --- Claude runner --------------------------------------------------------
107
+
108
+ function runClaude(prompt, claudeSessionId) {
109
+ return new Promise((resolve, reject) => {
110
+ // `--output-format json` returns a single JSON object with
111
+ // {result, session_id, is_error, total_cost_usd, ...}. Far simpler to
112
+ // consume than the NDJSON stream-json mode; we trade incremental
113
+ // progress for parse-once reliability.
114
+ const args = ['--print', '--output-format', 'json'];
115
+ if (claudeSessionId) args.push('--resume', claudeSessionId);
116
+
117
+ // spawn (not exec) — argv array means no shell interpretation, so the
118
+ // prompt content can't inject shell commands. The prompt itself goes
119
+ // through stdin to avoid ARG_MAX on long blueprint payloads (the god
120
+ // prompt easily exceeds 16 KB).
121
+ const child = spawn(CLAUDE_BIN, args, {
122
+ cwd: WORKDIR,
123
+ env: process.env,
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ });
126
+
127
+ let stdout = '';
128
+ let stderr = '';
129
+ let settled = false;
130
+ const settle = (fn) => (val) => {
131
+ if (settled) return;
132
+ settled = true;
133
+ fn(val);
134
+ };
135
+ const resolveOnce = settle(resolve);
136
+ const rejectOnce = settle(reject);
137
+
138
+ const killTimer = setTimeout(() => {
139
+ child.kill('SIGTERM');
140
+ rejectOnce(
141
+ new Error(`claude timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
142
+ );
143
+ }, TIMEOUT_MS);
144
+
145
+ child.stdout.on('data', (d) => (stdout += d));
146
+ child.stderr.on('data', (d) => (stderr += d));
147
+ child.on('error', (err) => {
148
+ clearTimeout(killTimer);
149
+ if (err.code === 'ENOENT') {
150
+ rejectOnce(
151
+ new Error(
152
+ `'${CLAUDE_BIN}' not found in PATH. Install: npm install -g @anthropic-ai/claude-code, then \`claude /login\`.`,
153
+ ),
154
+ );
155
+ return;
156
+ }
157
+ rejectOnce(err);
158
+ });
159
+ child.on('close', (code) => {
160
+ clearTimeout(killTimer);
161
+ if (code !== 0) {
162
+ rejectOnce(
163
+ new Error(
164
+ `claude exited ${code}${stderr ? `: ${stderr.trim().slice(0, 500)}` : ''}`,
165
+ ),
166
+ );
167
+ return;
168
+ }
169
+ try {
170
+ const result = JSON.parse(stdout);
171
+ resolveOnce({
172
+ text:
173
+ result.result || result.text || '(claude returned no text)',
174
+ sessionId: result.session_id || null,
175
+ isError: !!result.is_error,
176
+ });
177
+ } catch {
178
+ // Claude returned non-JSON for some reason — surface raw stdout so
179
+ // the user at least sees what happened.
180
+ resolveOnce({
181
+ text: stdout.trim() || '(no output)',
182
+ sessionId: null,
183
+ isError: false,
184
+ });
185
+ }
186
+ });
187
+
188
+ child.stdin.write(prompt);
189
+ child.stdin.end();
190
+ });
191
+ }
192
+
193
+ // --- HTTP server ----------------------------------------------------------
194
+
195
+ const app = express();
196
+ app.use(express.json({ limit: '5mb' }));
197
+
198
+ // Disable Express's default x-powered-by header and any tunnel-side
199
+ // caching surprises.
200
+ app.disable('x-powered-by');
201
+ app.use((req, res, next) => {
202
+ res.setHeader('Cache-Control', 'no-store');
203
+ next();
204
+ });
205
+
206
+ // Public health probe. No auth — anyone hitting the URL gets a tiny "is
207
+ // the bridge alive" answer. The Esque app's connection-test uses this for
208
+ // reachability before it sends a real prompt.
209
+ app.get('/', (_req, res) => {
210
+ res.json({
211
+ ok: true,
212
+ service: 'esque-bridge',
213
+ workdir: WORKDIR,
214
+ sessions: Object.keys(sessionMap).length,
215
+ });
216
+ });
217
+
218
+ // Pairing-check shim — Esque's `testConnectionDetailed()` sends a probe
219
+ // POST with `_probe: true`. Reply OK without invoking claude, and without
220
+ // requiring the secret (because the very first thing a freshly-paired app
221
+ // will do is run this check).
222
+ app.post('/', (req, res, next) => {
223
+ if (req.body && req.body._probe === true) {
224
+ return res.json({ ok: true, service: 'esque-bridge' });
225
+ }
226
+ return next();
227
+ });
228
+
229
+ function requireAuth(req, res, next) {
230
+ const provided = req.header('x-esque-pair') || req.body?.pairSecret;
231
+ if (provided !== PAIRING_SECRET) {
232
+ return res.status(401).json({
233
+ text:
234
+ 'Unauthorized. Pair this bridge with your phone by scanning the QR code from the terminal where esque-bridge is running.',
235
+ status: 'blocked',
236
+ });
237
+ }
238
+ next();
239
+ }
240
+
241
+ // The execute endpoint. Body: { prompt, sessionId?, pushToken? }.
242
+ // Returns: { text, status: 'finished' | 'blocked' }.
243
+ async function executeHandler(req, res) {
244
+ const body = req.body || {};
245
+ const prompt = String(body.prompt || '');
246
+ const esqueSessionId = body.sessionId ?? null;
247
+ if (!prompt.trim()) {
248
+ return res.status(400).json({ text: 'Empty prompt.', status: 'blocked' });
249
+ }
250
+
251
+ const claudeSid = esqueSessionId ? sessionMap[esqueSessionId] : null;
252
+ const preview = prompt.slice(0, 80).replace(/\s+/g, ' ');
253
+ console.log(
254
+ `[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} claude=${claudeSid ?? 'new'}`,
255
+ );
256
+
257
+ try {
258
+ const result = await runClaude(prompt, claudeSid);
259
+ if (result.sessionId && esqueSessionId) {
260
+ sessionMap[esqueSessionId] = result.sessionId;
261
+ saveSessions();
262
+ }
263
+ res.json({
264
+ text: result.text,
265
+ status: result.isError ? 'blocked' : 'finished',
266
+ });
267
+ } catch (err) {
268
+ console.error('[bridge] error:', err.message);
269
+ res
270
+ .status(500)
271
+ .json({ text: `Claude bridge failed: ${err.message}`, status: 'blocked' });
272
+ }
273
+ }
274
+
275
+ // Two routes for the same handler: `/` (what the Esque app POSTs to today)
276
+ // and `/execute` (the cleaner name the directive spec specified — kept so
277
+ // future iOS versions can switch without breaking the bridge).
278
+ app.post('/execute', requireAuth, executeHandler);
279
+ app.post('/', requireAuth, executeHandler);
280
+
281
+ // Anything else: 405.
282
+ app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
283
+
284
+ // --- Boot -----------------------------------------------------------------
285
+
286
+ async function main() {
287
+ if (!fs.existsSync(WORKDIR) || !fs.statSync(WORKDIR).isDirectory()) {
288
+ console.error(`workdir does not exist: ${WORKDIR}`);
289
+ process.exit(1);
290
+ }
291
+
292
+ await new Promise((resolve) => app.listen(PORT, resolve));
293
+
294
+ let tunnel;
295
+ try {
296
+ tunnel = await localtunnel({ port: PORT, subdomain: LT_SUBDOMAIN });
297
+ } catch (err) {
298
+ console.error('Failed to open localtunnel:', err.message);
299
+ console.error(
300
+ 'If localtunnel.me is blocked on your network, try cloudflared:',
301
+ );
302
+ console.error(` cloudflared tunnel --url http://localhost:${PORT}`);
303
+ process.exit(1);
304
+ }
305
+
306
+ const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}`;
307
+
308
+ console.log('');
309
+ console.log('━'.repeat(68));
310
+ console.log(' Esque Bridge Active');
311
+ console.log('━'.repeat(68));
312
+ console.log('');
313
+ console.log(
314
+ ' Scan this QR code with the Esque Agent mobile app to pair your device.',
315
+ );
316
+ console.log('');
317
+ qrcode.generate(pairUrl, { small: true });
318
+ console.log('');
319
+ console.log(` Local http://localhost:${PORT}`);
320
+ console.log(` Tunnel ${tunnel.url}`);
321
+ console.log(` Workdir ${WORKDIR}`);
322
+ console.log(` Claude bin ${CLAUDE_BIN}`);
323
+ console.log(
324
+ ` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (rotates on restart — don't share)`,
325
+ );
326
+ console.log('');
327
+ console.log(' Press Ctrl-C to stop.');
328
+ console.log('━'.repeat(68));
329
+
330
+ const shutdown = (signal) => {
331
+ console.log(`\n Received ${signal} — closing tunnel…`);
332
+ try {
333
+ tunnel.close();
334
+ } catch {
335
+ /* tunnel may already be closed */
336
+ }
337
+ process.exit(0);
338
+ };
339
+ process.on('SIGINT', () => shutdown('SIGINT'));
340
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
341
+
342
+ tunnel.on('close', () => {
343
+ console.error('\nTunnel closed unexpectedly. Restart `esque-bridge`.');
344
+ process.exit(1);
345
+ });
346
+ }
347
+
348
+ main().catch((err) => {
349
+ console.error('Fatal:', err.message);
350
+ process.exit(1);
351
+ });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "esque-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with your local Claude Code CLI via a tunnel + QR code, so prompts run through your Claude Pro / Max subscription instead of per-token API billing.",
5
+ "bin": {
6
+ "esque-bridge": "index.js"
7
+ },
8
+ "main": "index.js",
9
+ "scripts": {
10
+ "start": "node index.js"
11
+ },
12
+ "files": [
13
+ "index.js",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": [
20
+ "esque",
21
+ "claude",
22
+ "claude-code",
23
+ "anthropic",
24
+ "agent",
25
+ "bridge",
26
+ "mobile",
27
+ "ios"
28
+ ],
29
+ "dependencies": {
30
+ "express": "^4.21.1",
31
+ "localtunnel": "^2.0.2",
32
+ "qrcode-terminal": "^0.12.0"
33
+ },
34
+ "license": "MIT"
35
+ }