esque-bridge 0.1.0 → 0.2.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 +237 -94
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -2,20 +2,19 @@
2
2
  /**
3
3
  * esque-bridge — the desktop-side receiver for the Esque Agent mobile app.
4
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:
5
+ * Pairs your phone with a local coding-agent CLI (Claude Code by default,
6
+ * Aider, or any custom command) so prompts you send from the Esque app
7
+ * run through your subscription rather than per-token API billing.
8
8
  *
9
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
10
+ * 2. Opens a public localtunnel URL
11
+ * 3. Prints a QR code embedding `esque://pair?url=…&secret=…&agent=…`
12
+ * 4. Accepts POST /execute (pairing-secret-gated) and runs the prompt
13
+ * through the configured agent adapter, returning stdout
15
14
  *
16
- * Run it:
17
- * npx esque-bridge # zero-config
18
- * esque-bridge --workdir ~/my-project # point claude at a repo
15
+ * npx esque-bridge # default: claude
16
+ * npx esque-bridge --agent aider # Aider against the current repo
17
+ * npx esque-bridge --agent custom --cmd 'mycli --prompt {prompt}'
19
18
  */
20
19
 
21
20
  const express = require('express');
@@ -34,21 +33,31 @@ const argv = parseArgs(process.argv.slice(2));
34
33
  if (argv.help || argv.h) {
35
34
  console.log(
36
35
  `
37
- Esque Bridge — pair your phone with your local Claude Code.
36
+ Esque Bridge — pair your phone with a local coding-agent CLI.
38
37
 
39
38
  USAGE
40
- esque-bridge [--port 3030] [--workdir .] [--bin claude] [--timeout 300000]
39
+ esque-bridge [--agent claude|aider|custom] [--port 3030] [--workdir .]
40
+ [--cmd 'tool --prompt {prompt}'] [--bin <binary>]
41
+ [--timeout 300000]
42
+
43
+ AGENT ADAPTERS
44
+ claude (default) — uses Claude Code CLI (\`claude --print --output-format json\`).
45
+ Persists session ids so each conversation continues via --resume.
46
+ aider — uses Aider CLI (\`aider --message ... --yes-always --no-stream\`).
47
+ Conversation continuity is handled by aider's own .aider.chat.history.md.
48
+ custom — runs an arbitrary command. Pass --cmd 'tool --prompt {prompt}'
49
+ with literal {prompt} as a placeholder.
41
50
 
42
51
  OPTIONS
43
52
  --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)
53
+ --workdir Working directory for the agent (default: current dir)
54
+ --bin Override the agent's default binary
46
55
  --timeout Max ms per prompt before SIGTERM (default: 300000 = 5 min)
47
56
  --subdomain Request a stable localtunnel subdomain (optional)
48
57
 
49
58
  PREREQS
50
- npm install -g @anthropic-ai/claude-code
51
- claude /login # one-time OAuth
59
+ Claude: npm install -g @anthropic-ai/claude-code && claude /login
60
+ Aider: python -m pip install aider-chat
52
61
 
53
62
  OUTPUT
54
63
  Prints a QR code in your terminal. Scan it from the Esque iOS app to
@@ -63,7 +72,9 @@ const WORKDIR = path.resolve(
63
72
  argv.workdir || process.env.CLAUDE_WORKDIR || process.cwd(),
64
73
  );
65
74
  const TIMEOUT_MS = Number(argv.timeout || 5 * 60 * 1000);
66
- const CLAUDE_BIN = argv.bin || process.env.CLAUDE_BIN || 'claude';
75
+ const AGENT_TYPE = String(argv.agent || process.env.ESQUE_AGENT || 'claude').toLowerCase();
76
+ const CUSTOM_CMD = argv.cmd || process.env.ESQUE_CMD || null;
77
+ const BIN_OVERRIDE = argv.bin || null;
67
78
  const LT_SUBDOMAIN = argv.subdomain || process.env.LT_SUBDOMAIN || undefined;
68
79
  const PAIRING_SECRET = crypto.randomBytes(16).toString('hex');
69
80
  const SESSIONS_FILE = path.join(os.homedir(), '.esque-bridge-sessions.json');
@@ -86,8 +97,8 @@ function parseArgs(args) {
86
97
  }
87
98
 
88
99
  // --- Session persistence -------------------------------------------------
89
- // Map of {esqueSessionId → claudeSessionId} so each conversation continues
90
- // in-context across prompts via `claude --resume`. Survives bridge restarts.
100
+ // Per-agent map of {esqueSessionId → cliSessionId}. Each adapter that
101
+ // supports --resume reads/writes its own slot. Survives bridge restarts.
91
102
 
92
103
  let sessionMap = {};
93
104
  try {
@@ -102,23 +113,158 @@ function saveSessions() {
102
113
  console.warn('[bridge] could not persist session map:', err.message);
103
114
  }
104
115
  }
116
+ function getCliSessionId(agent, esqueSessionId) {
117
+ if (!esqueSessionId) return null;
118
+ return sessionMap[agent]?.[esqueSessionId] ?? null;
119
+ }
120
+ function setCliSessionId(agent, esqueSessionId, cliId) {
121
+ if (!cliId || !esqueSessionId) return;
122
+ if (!sessionMap[agent]) sessionMap[agent] = {};
123
+ sessionMap[agent][esqueSessionId] = cliId;
124
+ saveSessions();
125
+ }
126
+
127
+ // --- Adapters -------------------------------------------------------------
128
+ // Each adapter describes how to invoke a CLI for a single prompt. The
129
+ // runner is identical across adapters; only argv-building and stdout
130
+ // parsing differ.
131
+
132
+ const ADAPTERS = {
133
+ claude: {
134
+ label: 'Claude Code',
135
+ defaultBin: 'claude',
136
+ install:
137
+ "npm install -g @anthropic-ai/claude-code, then \`claude /login\` to authenticate.",
138
+ // `--output-format json` returns a single JSON object with
139
+ // {result, session_id, is_error, ...} — easy to parse, exact.
140
+ buildArgs(_prompt, prevSessionId) {
141
+ // `--dangerously-skip-permissions` lets Claude actually use its
142
+ // Write / Edit / Bash tools without an interactive approval prompt.
143
+ // In headless `--print` mode there's no way to say "yes" to a
144
+ // permission request, so without this every file write is silently
145
+ // blocked — the agent looks busy but can't touch the disk. This is
146
+ // the autonomous-bridge use case the flag exists for; access is
147
+ // already gated by the per-session pairing secret. (Mirrors Aider's
148
+ // `--yes-always` below.)
149
+ const args = [
150
+ '--print',
151
+ '--output-format',
152
+ 'json',
153
+ '--dangerously-skip-permissions',
154
+ ];
155
+ if (prevSessionId) args.push('--resume', prevSessionId);
156
+ return args;
157
+ },
158
+ parseOutput(stdout) {
159
+ try {
160
+ const r = JSON.parse(stdout);
161
+ return {
162
+ text: r.result || r.text || '(claude returned no text)',
163
+ cliSessionId: r.session_id || null,
164
+ isError: !!r.is_error,
165
+ };
166
+ } catch {
167
+ return { text: stdout.trim() || '(no output)', cliSessionId: null, isError: false };
168
+ }
169
+ },
170
+ },
171
+
172
+ aider: {
173
+ label: 'Aider',
174
+ defaultBin: 'aider',
175
+ install: 'python -m pip install aider-chat',
176
+ // Aider keeps its own .aider.chat.history.md per-directory, so we
177
+ // don't need to track a session id — every invocation against the
178
+ // same workdir picks up where the last one left off.
179
+ buildArgs(_prompt) {
180
+ return [
181
+ '--message', '-', // read message from stdin (we'll pipe it)
182
+ '--no-stream',
183
+ '--yes-always', // skip the "apply edit? y/n" prompts
184
+ '--no-pretty', // ANSI-free output for parsing
185
+ '--no-show-model-warnings',
186
+ ];
187
+ },
188
+ parseOutput(stdout) {
189
+ // Aider streams its assistant turn interleaved with diff blocks.
190
+ // Best-effort: return everything stdout produced.
191
+ return {
192
+ text: stdout.trim() || '(aider returned no text)',
193
+ cliSessionId: null,
194
+ isError: false,
195
+ };
196
+ },
197
+ },
198
+
199
+ custom: {
200
+ label: 'Custom',
201
+ defaultBin: null,
202
+ install:
203
+ "Pass --cmd 'your-cli --prompt {prompt}' (with literal {prompt} as placeholder).",
204
+ buildArgs(_prompt) {
205
+ if (!CUSTOM_CMD) {
206
+ throw new Error(
207
+ "--agent custom requires --cmd '<command-template>' with {prompt} placeholder.",
208
+ );
209
+ }
210
+ // Split the template on whitespace but preserve {prompt} as its own
211
+ // argv slot (so the runner can substitute the prompt body in).
212
+ return CUSTOM_CMD.split(/\s+/).filter(Boolean);
213
+ },
214
+ parseOutput(stdout) {
215
+ return {
216
+ text: stdout.trim() || '(no output)',
217
+ cliSessionId: null,
218
+ isError: false,
219
+ };
220
+ },
221
+ },
222
+ };
223
+
224
+ if (!ADAPTERS[AGENT_TYPE]) {
225
+ console.error(
226
+ `Unknown --agent: ${AGENT_TYPE}. Use one of: ${Object.keys(ADAPTERS).join(', ')}.`,
227
+ );
228
+ process.exit(1);
229
+ }
105
230
 
106
- // --- Claude runner --------------------------------------------------------
231
+ const adapter = ADAPTERS[AGENT_TYPE];
232
+ const AGENT_BIN = BIN_OVERRIDE || adapter.defaultBin;
107
233
 
108
- function runClaude(prompt, claudeSessionId) {
234
+ if (AGENT_TYPE === 'custom') {
235
+ if (!CUSTOM_CMD) {
236
+ console.error(
237
+ "--agent custom requires --cmd '<your-cli> {prompt}' (literal {prompt} substitution).",
238
+ );
239
+ process.exit(1);
240
+ }
241
+ }
242
+
243
+ // --- Runner ---------------------------------------------------------------
244
+
245
+ function runAgent(prompt, esqueSessionId) {
109
246
  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, {
247
+ const prevId = getCliSessionId(AGENT_TYPE, esqueSessionId);
248
+ let argv;
249
+ try {
250
+ argv = adapter.buildArgs(prompt, prevId);
251
+ } catch (err) {
252
+ reject(err);
253
+ return;
254
+ }
255
+
256
+ // For 'custom', substitute the {prompt} placeholder in argv. For
257
+ // built-in adapters, the prompt always rides via stdin (avoids
258
+ // ARG_MAX on long blueprint payloads).
259
+ let bin = AGENT_BIN;
260
+ let usesStdin = true;
261
+ if (AGENT_TYPE === 'custom') {
262
+ bin = argv.shift();
263
+ argv = argv.map((a) => a.replace('{prompt}', prompt));
264
+ usesStdin = !argv.some((a) => a.includes(prompt));
265
+ }
266
+
267
+ const child = spawn(bin, argv, {
122
268
  cwd: WORKDIR,
123
269
  env: process.env,
124
270
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -138,7 +284,7 @@ function runClaude(prompt, claudeSessionId) {
138
284
  const killTimer = setTimeout(() => {
139
285
  child.kill('SIGTERM');
140
286
  rejectOnce(
141
- new Error(`claude timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
287
+ new Error(`${adapter.label} timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
142
288
  );
143
289
  }, TIMEOUT_MS);
144
290
 
@@ -148,9 +294,7 @@ function runClaude(prompt, claudeSessionId) {
148
294
  clearTimeout(killTimer);
149
295
  if (err.code === 'ENOENT') {
150
296
  rejectOnce(
151
- new Error(
152
- `'${CLAUDE_BIN}' not found in PATH. Install: npm install -g @anthropic-ai/claude-code, then \`claude /login\`.`,
153
- ),
297
+ new Error(`'${bin}' not found in PATH. Install: ${adapter.install}`),
154
298
  );
155
299
  return;
156
300
  }
@@ -161,31 +305,25 @@ function runClaude(prompt, claudeSessionId) {
161
305
  if (code !== 0) {
162
306
  rejectOnce(
163
307
  new Error(
164
- `claude exited ${code}${stderr ? `: ${stderr.trim().slice(0, 500)}` : ''}`,
308
+ `${adapter.label} exited ${code}${stderr ? `: ${stderr.trim().slice(0, 500)}` : ''}`,
165
309
  ),
166
310
  );
167
311
  return;
168
312
  }
169
313
  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
- });
314
+ const parsed = adapter.parseOutput(stdout);
315
+ if (parsed.cliSessionId) {
316
+ setCliSessionId(AGENT_TYPE, esqueSessionId, parsed.cliSessionId);
317
+ }
318
+ resolveOnce(parsed);
319
+ } catch (err) {
320
+ rejectOnce(err);
185
321
  }
186
322
  });
187
323
 
188
- child.stdin.write(prompt);
324
+ if (usesStdin) {
325
+ child.stdin.write(prompt);
326
+ }
189
327
  child.stdin.end();
190
328
  });
191
329
  }
@@ -194,34 +332,28 @@ function runClaude(prompt, claudeSessionId) {
194
332
 
195
333
  const app = express();
196
334
  app.use(express.json({ limit: '5mb' }));
197
-
198
- // Disable Express's default x-powered-by header and any tunnel-side
199
- // caching surprises.
200
335
  app.disable('x-powered-by');
201
336
  app.use((req, res, next) => {
202
337
  res.setHeader('Cache-Control', 'no-store');
203
338
  next();
204
339
  });
205
340
 
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.
341
+ // Public health probe.
209
342
  app.get('/', (_req, res) => {
210
343
  res.json({
211
344
  ok: true,
212
345
  service: 'esque-bridge',
346
+ agent: AGENT_TYPE,
347
+ agentLabel: adapter.label,
213
348
  workdir: WORKDIR,
214
- sessions: Object.keys(sessionMap).length,
349
+ sessions: Object.keys(sessionMap[AGENT_TYPE] ?? {}).length,
215
350
  });
216
351
  });
217
352
 
218
- // Pairing-check shimEsque'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).
353
+ // Connection-test probeno auth.
222
354
  app.post('/', (req, res, next) => {
223
355
  if (req.body && req.body._probe === true) {
224
- return res.json({ ok: true, service: 'esque-bridge' });
356
+ return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE });
225
357
  }
226
358
  return next();
227
359
  });
@@ -238,8 +370,6 @@ function requireAuth(req, res, next) {
238
370
  next();
239
371
  }
240
372
 
241
- // The execute endpoint. Body: { prompt, sessionId?, pushToken? }.
242
- // Returns: { text, status: 'finished' | 'blocked' }.
243
373
  async function executeHandler(req, res) {
244
374
  const body = req.body || {};
245
375
  const prompt = String(body.prompt || '');
@@ -248,37 +378,53 @@ async function executeHandler(req, res) {
248
378
  return res.status(400).json({ text: 'Empty prompt.', status: 'blocked' });
249
379
  }
250
380
 
251
- const claudeSid = esqueSessionId ? sessionMap[esqueSessionId] : null;
381
+ const prev = getCliSessionId(AGENT_TYPE, esqueSessionId);
252
382
  const preview = prompt.slice(0, 80).replace(/\s+/g, ' ');
253
383
  console.log(
254
- `[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} claude=${claudeSid ?? 'new'}`,
384
+ `[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} ${AGENT_TYPE}=${prev ?? 'new'}`,
255
385
  );
256
386
 
257
- try {
258
- const result = await runClaude(prompt, claudeSid);
259
- if (result.sessionId && esqueSessionId) {
260
- sessionMap[esqueSessionId] = result.sessionId;
261
- saveSessions();
387
+ // Stream a keep-alive heartbeat while the agent thinks. `claude --print`
388
+ // emits nothing until it finishes (often 30–120s on a cold start with a big
389
+ // prompt), and localtunnel / intervening proxies close *idle* connections —
390
+ // which surfaced on the phone as "fetch request has been canceled". A space
391
+ // every few seconds keeps the connection active. The phone trims the body
392
+ // before JSON.parse, so leading whitespace is harmless.
393
+ res.status(200);
394
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
395
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
396
+ const heartbeat = setInterval(() => {
397
+ try {
398
+ res.write(' ');
399
+ } catch {
400
+ /* socket already closed */
262
401
  }
263
- res.json({
402
+ }, 5000);
403
+ const finish = (payload) => {
404
+ clearInterval(heartbeat);
405
+ try {
406
+ res.end(JSON.stringify(payload));
407
+ } catch {
408
+ /* socket already closed */
409
+ }
410
+ };
411
+
412
+ try {
413
+ const result = await runAgent(prompt, esqueSessionId);
414
+ finish({
264
415
  text: result.text,
265
416
  status: result.isError ? 'blocked' : 'finished',
266
417
  });
267
418
  } catch (err) {
268
419
  console.error('[bridge] error:', err.message);
269
- res
270
- .status(500)
271
- .json({ text: `Claude bridge failed: ${err.message}`, status: 'blocked' });
420
+ // Headers are already sent (heartbeat), so the error rides in the body
421
+ // with status 'blocked' rather than an HTTP 500.
422
+ finish({ text: `${adapter.label} failed: ${err.message}`, status: 'blocked' });
272
423
  }
273
424
  }
274
425
 
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
426
  app.post('/execute', requireAuth, executeHandler);
279
427
  app.post('/', requireAuth, executeHandler);
280
-
281
- // Anything else: 405.
282
428
  app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
283
429
 
284
430
  // --- Boot -----------------------------------------------------------------
@@ -296,30 +442,27 @@ async function main() {
296
442
  tunnel = await localtunnel({ port: PORT, subdomain: LT_SUBDOMAIN });
297
443
  } catch (err) {
298
444
  console.error('Failed to open localtunnel:', err.message);
299
- console.error(
300
- 'If localtunnel.me is blocked on your network, try cloudflared:',
301
- );
445
+ console.error('If localtunnel.me is blocked, try cloudflared:');
302
446
  console.error(` cloudflared tunnel --url http://localhost:${PORT}`);
303
447
  process.exit(1);
304
448
  }
305
449
 
306
- const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}`;
450
+ const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}&agent=${AGENT_TYPE}`;
307
451
 
308
452
  console.log('');
309
453
  console.log('━'.repeat(68));
310
- console.log(' Esque Bridge Active');
454
+ console.log(` Esque Bridge Active · ${adapter.label}`);
311
455
  console.log('━'.repeat(68));
312
456
  console.log('');
313
- console.log(
314
- ' Scan this QR code with the Esque Agent mobile app to pair your device.',
315
- );
457
+ console.log(' Scan this QR code with the Esque Agent mobile app to pair.');
316
458
  console.log('');
317
459
  qrcode.generate(pairUrl, { small: true });
318
460
  console.log('');
461
+ console.log(` Agent ${adapter.label} (${AGENT_TYPE})`);
319
462
  console.log(` Local http://localhost:${PORT}`);
320
463
  console.log(` Tunnel ${tunnel.url}`);
321
464
  console.log(` Workdir ${WORKDIR}`);
322
- console.log(` Claude bin ${CLAUDE_BIN}`);
465
+ console.log(` Binary ${AGENT_BIN ?? '(custom)'}`);
323
466
  console.log(
324
467
  ` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (rotates on restart — don't share)`,
325
468
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.2.1",
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"
7
7
  },