esque-bridge 0.1.0 → 0.2.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 +196 -91
  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,145 @@ 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
+ const args = ['--print', '--output-format', 'json'];
142
+ if (prevSessionId) args.push('--resume', prevSessionId);
143
+ return args;
144
+ },
145
+ parseOutput(stdout) {
146
+ try {
147
+ const r = JSON.parse(stdout);
148
+ return {
149
+ text: r.result || r.text || '(claude returned no text)',
150
+ cliSessionId: r.session_id || null,
151
+ isError: !!r.is_error,
152
+ };
153
+ } catch {
154
+ return { text: stdout.trim() || '(no output)', cliSessionId: null, isError: false };
155
+ }
156
+ },
157
+ },
158
+
159
+ aider: {
160
+ label: 'Aider',
161
+ defaultBin: 'aider',
162
+ install: 'python -m pip install aider-chat',
163
+ // Aider keeps its own .aider.chat.history.md per-directory, so we
164
+ // don't need to track a session id — every invocation against the
165
+ // same workdir picks up where the last one left off.
166
+ buildArgs(_prompt) {
167
+ return [
168
+ '--message', '-', // read message from stdin (we'll pipe it)
169
+ '--no-stream',
170
+ '--yes-always', // skip the "apply edit? y/n" prompts
171
+ '--no-pretty', // ANSI-free output for parsing
172
+ '--no-show-model-warnings',
173
+ ];
174
+ },
175
+ parseOutput(stdout) {
176
+ // Aider streams its assistant turn interleaved with diff blocks.
177
+ // Best-effort: return everything stdout produced.
178
+ return {
179
+ text: stdout.trim() || '(aider returned no text)',
180
+ cliSessionId: null,
181
+ isError: false,
182
+ };
183
+ },
184
+ },
185
+
186
+ custom: {
187
+ label: 'Custom',
188
+ defaultBin: null,
189
+ install:
190
+ "Pass --cmd 'your-cli --prompt {prompt}' (with literal {prompt} as placeholder).",
191
+ buildArgs(_prompt) {
192
+ if (!CUSTOM_CMD) {
193
+ throw new Error(
194
+ "--agent custom requires --cmd '<command-template>' with {prompt} placeholder.",
195
+ );
196
+ }
197
+ // Split the template on whitespace but preserve {prompt} as its own
198
+ // argv slot (so the runner can substitute the prompt body in).
199
+ return CUSTOM_CMD.split(/\s+/).filter(Boolean);
200
+ },
201
+ parseOutput(stdout) {
202
+ return {
203
+ text: stdout.trim() || '(no output)',
204
+ cliSessionId: null,
205
+ isError: false,
206
+ };
207
+ },
208
+ },
209
+ };
210
+
211
+ if (!ADAPTERS[AGENT_TYPE]) {
212
+ console.error(
213
+ `Unknown --agent: ${AGENT_TYPE}. Use one of: ${Object.keys(ADAPTERS).join(', ')}.`,
214
+ );
215
+ process.exit(1);
216
+ }
217
+
218
+ const adapter = ADAPTERS[AGENT_TYPE];
219
+ const AGENT_BIN = BIN_OVERRIDE || adapter.defaultBin;
220
+
221
+ if (AGENT_TYPE === 'custom') {
222
+ if (!CUSTOM_CMD) {
223
+ console.error(
224
+ "--agent custom requires --cmd '<your-cli> {prompt}' (literal {prompt} substitution).",
225
+ );
226
+ process.exit(1);
227
+ }
228
+ }
105
229
 
106
- // --- Claude runner --------------------------------------------------------
230
+ // --- Runner ---------------------------------------------------------------
107
231
 
108
- function runClaude(prompt, claudeSessionId) {
232
+ function runAgent(prompt, esqueSessionId) {
109
233
  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, {
234
+ const prevId = getCliSessionId(AGENT_TYPE, esqueSessionId);
235
+ let argv;
236
+ try {
237
+ argv = adapter.buildArgs(prompt, prevId);
238
+ } catch (err) {
239
+ reject(err);
240
+ return;
241
+ }
242
+
243
+ // For 'custom', substitute the {prompt} placeholder in argv. For
244
+ // built-in adapters, the prompt always rides via stdin (avoids
245
+ // ARG_MAX on long blueprint payloads).
246
+ let bin = AGENT_BIN;
247
+ let usesStdin = true;
248
+ if (AGENT_TYPE === 'custom') {
249
+ bin = argv.shift();
250
+ argv = argv.map((a) => a.replace('{prompt}', prompt));
251
+ usesStdin = !argv.some((a) => a.includes(prompt));
252
+ }
253
+
254
+ const child = spawn(bin, argv, {
122
255
  cwd: WORKDIR,
123
256
  env: process.env,
124
257
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -138,7 +271,7 @@ function runClaude(prompt, claudeSessionId) {
138
271
  const killTimer = setTimeout(() => {
139
272
  child.kill('SIGTERM');
140
273
  rejectOnce(
141
- new Error(`claude timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
274
+ new Error(`${adapter.label} timed out after ${Math.round(TIMEOUT_MS / 1000)}s`),
142
275
  );
143
276
  }, TIMEOUT_MS);
144
277
 
@@ -148,9 +281,7 @@ function runClaude(prompt, claudeSessionId) {
148
281
  clearTimeout(killTimer);
149
282
  if (err.code === 'ENOENT') {
150
283
  rejectOnce(
151
- new Error(
152
- `'${CLAUDE_BIN}' not found in PATH. Install: npm install -g @anthropic-ai/claude-code, then \`claude /login\`.`,
153
- ),
284
+ new Error(`'${bin}' not found in PATH. Install: ${adapter.install}`),
154
285
  );
155
286
  return;
156
287
  }
@@ -161,31 +292,25 @@ function runClaude(prompt, claudeSessionId) {
161
292
  if (code !== 0) {
162
293
  rejectOnce(
163
294
  new Error(
164
- `claude exited ${code}${stderr ? `: ${stderr.trim().slice(0, 500)}` : ''}`,
295
+ `${adapter.label} exited ${code}${stderr ? `: ${stderr.trim().slice(0, 500)}` : ''}`,
165
296
  ),
166
297
  );
167
298
  return;
168
299
  }
169
300
  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
- });
301
+ const parsed = adapter.parseOutput(stdout);
302
+ if (parsed.cliSessionId) {
303
+ setCliSessionId(AGENT_TYPE, esqueSessionId, parsed.cliSessionId);
304
+ }
305
+ resolveOnce(parsed);
306
+ } catch (err) {
307
+ rejectOnce(err);
185
308
  }
186
309
  });
187
310
 
188
- child.stdin.write(prompt);
311
+ if (usesStdin) {
312
+ child.stdin.write(prompt);
313
+ }
189
314
  child.stdin.end();
190
315
  });
191
316
  }
@@ -194,34 +319,28 @@ function runClaude(prompt, claudeSessionId) {
194
319
 
195
320
  const app = express();
196
321
  app.use(express.json({ limit: '5mb' }));
197
-
198
- // Disable Express's default x-powered-by header and any tunnel-side
199
- // caching surprises.
200
322
  app.disable('x-powered-by');
201
323
  app.use((req, res, next) => {
202
324
  res.setHeader('Cache-Control', 'no-store');
203
325
  next();
204
326
  });
205
327
 
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.
328
+ // Public health probe.
209
329
  app.get('/', (_req, res) => {
210
330
  res.json({
211
331
  ok: true,
212
332
  service: 'esque-bridge',
333
+ agent: AGENT_TYPE,
334
+ agentLabel: adapter.label,
213
335
  workdir: WORKDIR,
214
- sessions: Object.keys(sessionMap).length,
336
+ sessions: Object.keys(sessionMap[AGENT_TYPE] ?? {}).length,
215
337
  });
216
338
  });
217
339
 
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).
340
+ // Connection-test probeno auth.
222
341
  app.post('/', (req, res, next) => {
223
342
  if (req.body && req.body._probe === true) {
224
- return res.json({ ok: true, service: 'esque-bridge' });
343
+ return res.json({ ok: true, service: 'esque-bridge', agent: AGENT_TYPE });
225
344
  }
226
345
  return next();
227
346
  });
@@ -238,8 +357,6 @@ function requireAuth(req, res, next) {
238
357
  next();
239
358
  }
240
359
 
241
- // The execute endpoint. Body: { prompt, sessionId?, pushToken? }.
242
- // Returns: { text, status: 'finished' | 'blocked' }.
243
360
  async function executeHandler(req, res) {
244
361
  const body = req.body || {};
245
362
  const prompt = String(body.prompt || '');
@@ -248,18 +365,14 @@ async function executeHandler(req, res) {
248
365
  return res.status(400).json({ text: 'Empty prompt.', status: 'blocked' });
249
366
  }
250
367
 
251
- const claudeSid = esqueSessionId ? sessionMap[esqueSessionId] : null;
368
+ const prev = getCliSessionId(AGENT_TYPE, esqueSessionId);
252
369
  const preview = prompt.slice(0, 80).replace(/\s+/g, ' ');
253
370
  console.log(
254
- `[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} claude=${claudeSid ?? 'new'}`,
371
+ `[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} ${AGENT_TYPE}=${prev ?? 'new'}`,
255
372
  );
256
373
 
257
374
  try {
258
- const result = await runClaude(prompt, claudeSid);
259
- if (result.sessionId && esqueSessionId) {
260
- sessionMap[esqueSessionId] = result.sessionId;
261
- saveSessions();
262
- }
375
+ const result = await runAgent(prompt, esqueSessionId);
263
376
  res.json({
264
377
  text: result.text,
265
378
  status: result.isError ? 'blocked' : 'finished',
@@ -268,17 +381,12 @@ async function executeHandler(req, res) {
268
381
  console.error('[bridge] error:', err.message);
269
382
  res
270
383
  .status(500)
271
- .json({ text: `Claude bridge failed: ${err.message}`, status: 'blocked' });
384
+ .json({ text: `${adapter.label} failed: ${err.message}`, status: 'blocked' });
272
385
  }
273
386
  }
274
387
 
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
388
  app.post('/execute', requireAuth, executeHandler);
279
389
  app.post('/', requireAuth, executeHandler);
280
-
281
- // Anything else: 405.
282
390
  app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
283
391
 
284
392
  // --- Boot -----------------------------------------------------------------
@@ -296,30 +404,27 @@ async function main() {
296
404
  tunnel = await localtunnel({ port: PORT, subdomain: LT_SUBDOMAIN });
297
405
  } catch (err) {
298
406
  console.error('Failed to open localtunnel:', err.message);
299
- console.error(
300
- 'If localtunnel.me is blocked on your network, try cloudflared:',
301
- );
407
+ console.error('If localtunnel.me is blocked, try cloudflared:');
302
408
  console.error(` cloudflared tunnel --url http://localhost:${PORT}`);
303
409
  process.exit(1);
304
410
  }
305
411
 
306
- const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}`;
412
+ const pairUrl = `esque://pair?url=${encodeURIComponent(tunnel.url)}&secret=${PAIRING_SECRET}&agent=${AGENT_TYPE}`;
307
413
 
308
414
  console.log('');
309
415
  console.log('━'.repeat(68));
310
- console.log(' Esque Bridge Active');
416
+ console.log(` Esque Bridge Active · ${adapter.label}`);
311
417
  console.log('━'.repeat(68));
312
418
  console.log('');
313
- console.log(
314
- ' Scan this QR code with the Esque Agent mobile app to pair your device.',
315
- );
419
+ console.log(' Scan this QR code with the Esque Agent mobile app to pair.');
316
420
  console.log('');
317
421
  qrcode.generate(pairUrl, { small: true });
318
422
  console.log('');
423
+ console.log(` Agent ${adapter.label} (${AGENT_TYPE})`);
319
424
  console.log(` Local http://localhost:${PORT}`);
320
425
  console.log(` Tunnel ${tunnel.url}`);
321
426
  console.log(` Workdir ${WORKDIR}`);
322
- console.log(` Claude bin ${CLAUDE_BIN}`);
427
+ console.log(` Binary ${AGENT_BIN ?? '(custom)'}`);
323
428
  console.log(
324
429
  ` Pair secret ${PAIRING_SECRET.slice(0, 8)}… (rotates on restart — don't share)`,
325
430
  );
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.0",
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
  },