esque-bridge 0.6.14 → 0.6.15

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 +134 -19
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -87,6 +87,9 @@ const WORKDIR = path.resolve(
87
87
  // scaffolds) finish. Override with --timeout.
88
88
  const TIMEOUT_MS = Number(argv.timeout || 20 * 60 * 1000);
89
89
  const AGENT_TYPE = String(argv.agent || process.env.ESQUE_AGENT || 'claude').toLowerCase();
90
+ // Optional bridge-side model override; the phone's per-request model wins over
91
+ // this, and if neither is set the agent CLI uses its own default model.
92
+ const MODEL_OVERRIDE = argv.model || process.env.ESQUE_MODEL || null;
90
93
  const CUSTOM_CMD = argv.cmd || process.env.ESQUE_CMD || null;
91
94
  const BIN_OVERRIDE = argv.bin || null;
92
95
  const LT_SUBDOMAIN = argv.subdomain || process.env.LT_SUBDOMAIN || undefined;
@@ -245,7 +248,7 @@ const ADAPTERS = {
245
248
  "npm install -g @anthropic-ai/claude-code, then \`claude /login\` to authenticate.",
246
249
  // `--output-format json` returns a single JSON object with
247
250
  // {result, session_id, is_error, ...} — easy to parse, exact.
248
- buildArgs(_prompt, prevSessionId) {
251
+ buildArgs(_prompt, prevSessionId, model) {
249
252
  // `--dangerously-skip-permissions` lets Claude actually use its
250
253
  // Write / Edit / Bash tools without an interactive approval prompt.
251
254
  // In headless `--print` mode there's no way to say "yes" to a
@@ -260,6 +263,9 @@ const ADAPTERS = {
260
263
  'json',
261
264
  '--dangerously-skip-permissions',
262
265
  ];
266
+ // Optional per-agent model pin from the phone (Settings → Agent models).
267
+ // Claude accepts an alias (opus/sonnet/haiku) or a full model id.
268
+ if (model) args.push('--model', model);
263
269
  if (prevSessionId) args.push('--resume', prevSessionId);
264
270
  return args;
265
271
  },
@@ -270,9 +276,22 @@ const ADAPTERS = {
270
276
  text: r.result || r.text || '(claude returned no text)',
271
277
  cliSessionId: r.session_id || null,
272
278
  isError: !!r.is_error,
279
+ // Claude Code's JSON result already carries per-turn spend + tokens —
280
+ // free to surface (no extra process). Remaining WEEKLY/5h plan quota
281
+ // is NOT available headless (only the interactive `/usage` TUI), so we
282
+ // report cost + tokens per turn and let the phone keep a running total.
283
+ usage: {
284
+ agent: 'claude',
285
+ costUsd: typeof r.total_cost_usd === 'number' ? r.total_cost_usd : null,
286
+ inputTokens: r.usage?.input_tokens ?? null,
287
+ outputTokens: r.usage?.output_tokens ?? null,
288
+ cacheReadTokens: r.usage?.cache_read_input_tokens ?? null,
289
+ numTurns: r.num_turns ?? null,
290
+ durationMs: r.duration_ms ?? null,
291
+ },
273
292
  };
274
293
  } catch {
275
- return { text: stdout.trim() || '(no output)', cliSessionId: null, isError: false };
294
+ return { text: stdout.trim() || '(no output)', cliSessionId: null, isError: false, usage: null };
276
295
  }
277
296
  },
278
297
  },
@@ -289,12 +308,14 @@ const ADAPTERS = {
289
308
  // looks busy but can't touch the disk. Access is already gated by the
290
309
  // pairing secret + the startup workdir confirmation. --skip-git-repo-check
291
310
  // lets it run in a brand-new (not-yet-git) project dir for `fresh` builds.
292
- buildArgs(_prompt, _prevSessionId) {
293
- return [
311
+ buildArgs(_prompt, _prevSessionId, model) {
312
+ const args = [
294
313
  'exec',
295
314
  '--dangerously-bypass-approvals-and-sandbox',
296
315
  '--skip-git-repo-check',
297
316
  ];
317
+ if (model) args.push('--model', model); // optional per-agent model pin
318
+ return args;
298
319
  },
299
320
  parseOutput(stdout) {
300
321
  // `codex exec` streams its run to stdout and logs to stderr; the trimmed
@@ -304,6 +325,13 @@ const ADAPTERS = {
304
325
  text: stdout.trim() || '(codex returned no output)',
305
326
  cliSessionId: null,
306
327
  isError: false,
328
+ // Per-turn tokens ARE available, but only by switching `exec` to
329
+ // `--json` (usage rides a `turn.completed` event) + `--output-last-message`
330
+ // to keep the reply text clean — a change to the invocation that needs a
331
+ // real Codex test before shipping, so it's deferred. Remaining rate-limit
332
+ // quota is NOT available in exec mode at all (rate_limits is null;
333
+ // openai/codex#14728). Reports null until the --json migration lands.
334
+ usage: null,
307
335
  };
308
336
  },
309
337
  },
@@ -321,22 +349,45 @@ const ADAPTERS = {
321
349
  // "--message - = read stdin" convention; passing '-' would send the
322
350
  // agent a literal one-character message.
323
351
  promptInArgs: true,
324
- buildArgs(prompt) {
325
- return [
352
+ buildArgs(prompt, _prevSessionId, model) {
353
+ const args = [
326
354
  '--message', prompt,
327
355
  '--no-stream',
328
356
  '--yes-always', // skip the "apply edit? y/n" prompts
329
357
  '--no-pretty', // ANSI-free output for parsing
330
358
  '--no-show-model-warnings',
331
359
  ];
360
+ if (model) args.push('--model', model); // optional per-agent model pin
361
+ return args;
332
362
  },
333
363
  parseOutput(stdout) {
334
364
  // Aider streams its assistant turn interleaved with diff blocks.
335
365
  // Best-effort: return everything stdout produced.
366
+ // Aider auto-prints a cost line after each exchange (we already run
367
+ // --no-pretty so it's ANSI-free): "Tokens: 2.8k sent, 112 received.
368
+ // Cost: $0.01 message, $0.01 session." The session figure accumulates
369
+ // across turns in the same workdir, so it's a real running total.
370
+ const m = stdout.match(
371
+ /Tokens:\s*([\d.]+[kMK]?)\s*sent,\s*([\d.]+[kMK]?)\s*received\.\s*Cost:\s*\$([\d.]+)\s*message,\s*\$([\d.]+)\s*session/i,
372
+ );
373
+ const scale = (s) => {
374
+ const v = parseFloat(s);
375
+ return /m$/i.test(s) ? v * 1e6 : /k$/i.test(s) ? v * 1e3 : v;
376
+ };
377
+ const usage = m
378
+ ? {
379
+ agent: 'aider',
380
+ inputTokens: Math.round(scale(m[1])),
381
+ outputTokens: Math.round(scale(m[2])),
382
+ costUsd: parseFloat(m[3]),
383
+ costUsdSession: parseFloat(m[4]),
384
+ }
385
+ : null;
336
386
  return {
337
387
  text: stdout.trim() || '(aider returned no text)',
338
388
  cliSessionId: null,
339
389
  isError: false,
390
+ usage,
340
391
  };
341
392
  },
342
393
  },
@@ -393,12 +444,12 @@ if (AGENT_TYPE === 'custom') {
393
444
  // editing files after the bridge is gone.
394
445
  let activeAgentKill = null;
395
446
 
396
- function runAgent(prompt, esqueSessionId) {
447
+ function runAgent(prompt, esqueSessionId, model) {
397
448
  return new Promise((resolve, reject) => {
398
449
  const prevId = getCliSessionId(AGENT_TYPE, esqueSessionId);
399
450
  let argv;
400
451
  try {
401
- argv = adapter.buildArgs(prompt, prevId);
452
+ argv = adapter.buildArgs(prompt, prevId, model);
402
453
  } catch (err) {
403
454
  reject(err);
404
455
  return;
@@ -575,10 +626,10 @@ function runAgent(prompt, esqueSessionId) {
575
626
  // bricked forever. Detect that specific failure, drop the mapping, and rerun
576
627
  // once as a fresh CLI session.
577
628
  const RESUME_FAIL_RE = /no conversation found|session.*not found|unknown session|invalid session/i;
578
- async function runAgentResilient(prompt, esqueSessionId) {
629
+ async function runAgentResilient(prompt, esqueSessionId, model) {
579
630
  const prevId = getCliSessionId(AGENT_TYPE, esqueSessionId);
580
631
  try {
581
- return await runAgent(prompt, esqueSessionId);
632
+ return await runAgent(prompt, esqueSessionId, model);
582
633
  } catch (err) {
583
634
  if (prevId && RESUME_FAIL_RE.test(String(err && err.message))) {
584
635
  console.warn('[bridge] stored CLI session is gone — retrying as a fresh session');
@@ -596,7 +647,7 @@ async function runAgentResilient(prompt, esqueSessionId) {
596
647
  } catch {
597
648
  /* fall back to the bare prompt */
598
649
  }
599
- const fresh = await runAgent(recovered, esqueSessionId);
650
+ const fresh = await runAgent(recovered, esqueSessionId, model);
600
651
  // Tell the phone the agent's memory was reset (it ignores the field if
601
652
  // unknown). Older bridges never set it.
602
653
  return { ...fresh, sessionReset: true };
@@ -855,6 +906,45 @@ function waitForPort(port, timeoutMs) {
855
906
  });
856
907
  }
857
908
 
909
+ // Reclaim `port` from any process already listening on it. A stale/foreign dev
910
+ // server squatting on the preview port is the #1 cause of "the preview shows
911
+ // the WRONG project": waitForPort() only checks that SOMETHING is listening, so
912
+ // a leftover Metro/Vite/expo from another repo would get tunneled instead of the
913
+ // server we just started. Esque owns the preview port, so we forcibly clear it
914
+ // before starting ours. Best-effort + synchronous (we're about to rebind).
915
+ // Returns the number of processes killed.
916
+ function freePort(port) {
917
+ try {
918
+ if (isWindows) {
919
+ const out = execSync(`netstat -ano -p tcp | findstr :${port}`, {
920
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
921
+ });
922
+ const pids = new Set();
923
+ out.split('\n').forEach((l) => {
924
+ const m = l.trim().match(/LISTENING\s+(\d+)\s*$/i);
925
+ if (m) pids.add(m[1]);
926
+ });
927
+ pids.forEach((pid) => {
928
+ try { execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' }); } catch { /* already gone */ }
929
+ });
930
+ return pids.size;
931
+ }
932
+ const out = execSync(`lsof -ti tcp:${port} -sTCP:LISTEN`, {
933
+ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
934
+ }).trim();
935
+ if (!out) return 0;
936
+ const pids = out.split('\n').map((s) => s.trim()).filter(Boolean);
937
+ pids.forEach((pid) => {
938
+ try { process.kill(Number(pid), 'SIGKILL'); } catch { /* already gone */ }
939
+ });
940
+ return pids.length;
941
+ } catch {
942
+ // lsof/netstat exit non-zero when nothing is on the port — that's the
943
+ // common (port-is-free) case, not an error.
944
+ return 0;
945
+ }
946
+ }
947
+
858
948
  function hasCloudflared() {
859
949
  try { execSync(isWindows ? 'where cloudflared' : 'which cloudflared', { stdio: 'ignore' }); return true; }
860
950
  catch { return false; }
@@ -966,6 +1056,15 @@ async function probeBuildError(port) {
966
1056
  if (!root) return null; // couldn't reach it — don't cry wolf
967
1057
  if (root.status >= 500) return summarizeBuildError(root.status, root.body);
968
1058
 
1059
+ // A NATIVE Expo dev server (`expo start`, without --web) serves a JSON
1060
+ // MANIFEST at the root — not a web page — so the phone's webview shows raw
1061
+ // JSON / a blank screen. The web preview must use `expo start --web` (React
1062
+ // Native Web), which serves HTML. If we see a native manifest, say exactly
1063
+ // what's wrong instead of handing the phone something it can't render.
1064
+ if (/"launchAsset"/.test(root.body) && /AppEntry|expoGo|packagerOpts|debuggerHost/.test(root.body)) {
1065
+ return 'This preview is a NATIVE Expo dev server (expo start), which serves a manifest the phone cannot render — you would see raw JSON or a blank screen. The web preview must run React Native Web: npx expo install react-native-web react-dom @expo/metro-runtime && npx expo start --web';
1066
+ }
1067
+
969
1068
  // Expo/Metro: the HTML points at a *.bundle script. Fetching it forces the
970
1069
  // compile and surfaces the react-native-web class of error as a 500.
971
1070
  const m = root.body.match(/<script[^>]+src="([^"]*\.bundle[^"]*)"/i);
@@ -1111,6 +1210,16 @@ async function startPreview(cmd, port) {
1111
1210
  console.error(`[preview] dependency install failed:\n${deps.error}`);
1112
1211
  return { url: null, buildError: deps.error };
1113
1212
  }
1213
+ // Reclaim the port from any stale/foreign dev server BEFORE starting ours.
1214
+ // Without this, a leftover server from another project (e.g. a manual
1215
+ // `expo start` left running on 8081) would be what waitForPort() latches onto,
1216
+ // and we'd tunnel the WRONG project to the phone. Give the port a moment to
1217
+ // actually release before we try to bind it.
1218
+ const cleared = freePort(port);
1219
+ if (cleared) {
1220
+ console.log(`[preview] cleared ${cleared} stale process(es) on port ${port} before starting`);
1221
+ await new Promise((r) => setTimeout(r, 400));
1222
+ }
1114
1223
  console.log(`[preview] starting: ${cmd} (port ${port})`);
1115
1224
  // Remember the command now (not on success): a revival attempt after a
1116
1225
  // mid-start crash should still know what to run.
@@ -1211,8 +1320,8 @@ async function applyPreview(text) {
1211
1320
  return { text: text.replace(PREVIEW_RE, () => replacement), buildError };
1212
1321
  }
1213
1322
 
1214
- async function runAgentWithPreview(prompt, sessionId) {
1215
- const result = await runAgentResilient(prompt, sessionId);
1323
+ async function runAgentWithPreview(prompt, sessionId, model) {
1324
+ const result = await runAgentResilient(prompt, sessionId, model);
1216
1325
  if (!result || !result.text) return result;
1217
1326
 
1218
1327
  const applied = await applyPreview(result.text);
@@ -1231,7 +1340,7 @@ async function runAgentWithPreview(prompt, sessionId) {
1231
1340
  `Fix the root cause in the code, then re-emit the ESQUE_PREVIEW marker on ` +
1232
1341
  `its own line. Keep the explanation brief — make the fix and output the marker.`;
1233
1342
  let fix = null;
1234
- try { fix = await runAgentResilient(fixPrompt, sessionId); } catch (e) { console.error('[preview] auto-fix run failed:', e.message); }
1343
+ try { fix = await runAgentResilient(fixPrompt, sessionId, model); } catch (e) { console.error('[preview] auto-fix run failed:', e.message); }
1235
1344
  if (fix && fix.text) {
1236
1345
  const fixApplied = await applyPreview(fix.text);
1237
1346
  result.text = `${applied.text}\n\n— Auto-fix attempt —\n${fixApplied.text}`;
@@ -1305,8 +1414,8 @@ async function sendExpoPush(token, kind, sessionId, title, message) {
1305
1414
  // races two --resume's of the same CLI session, and collides in the preview
1306
1415
  // pipeline. A simple promise chain keeps arrival order and isolates failures.
1307
1416
  let runQueue = Promise.resolve();
1308
- function enqueueRun(prompt, sessionId) {
1309
- const run = runQueue.then(() => runAgentWithPreview(prompt, sessionId));
1417
+ function enqueueRun(prompt, sessionId, model) {
1418
+ const run = runQueue.then(() => runAgentWithPreview(prompt, sessionId, model));
1310
1419
  runQueue = run.then(() => undefined, () => undefined);
1311
1420
  return run;
1312
1421
  }
@@ -1316,6 +1425,10 @@ async function executeHandler(req, res) {
1316
1425
  const prompt = String(body.prompt || '');
1317
1426
  const esqueSessionId = body.sessionId ?? null;
1318
1427
  const pushToken = typeof body.pushToken === 'string' ? body.pushToken : null;
1428
+ // Optional per-agent model pin sent by the phone (Settings → Agent models).
1429
+ // Falls back to a bridge-side --model / ESQUE_MODEL override if the phone
1430
+ // sends none, else the agent CLI's own default model.
1431
+ const model = typeof body.model === 'string' && body.model.trim() ? body.model.trim() : MODEL_OVERRIDE;
1319
1432
  if (!prompt.trim()) {
1320
1433
  return res.status(400).json({ text: 'Empty prompt.', status: 'blocked' });
1321
1434
  }
@@ -1336,12 +1449,13 @@ async function executeHandler(req, res) {
1336
1449
  const jobId = newJobId();
1337
1450
  jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
1338
1451
  res.json({ jobId, status: 'working' });
1339
- enqueueRun(prompt, esqueSessionId)
1452
+ enqueueRun(prompt, esqueSessionId, model)
1340
1453
  .then((result) => {
1341
1454
  jobs.set(jobId, {
1342
1455
  status: result.isError ? 'blocked' : 'finished',
1343
1456
  text: result.text,
1344
1457
  sessionReset: !!result.sessionReset,
1458
+ usage: result.usage ?? null,
1345
1459
  createdAt: Date.now(),
1346
1460
  });
1347
1461
  console.log(
@@ -1393,12 +1507,13 @@ async function executeHandler(req, res) {
1393
1507
  };
1394
1508
 
1395
1509
  try {
1396
- const result = await enqueueRun(prompt, esqueSessionId);
1510
+ const result = await enqueueRun(prompt, esqueSessionId, model);
1397
1511
  if (!result.isError) appendHandoff(esqueSessionId, prompt, result.text);
1398
1512
  finish({
1399
1513
  text: result.text,
1400
1514
  status: result.isError ? 'blocked' : 'finished',
1401
1515
  sessionReset: !!result.sessionReset,
1516
+ usage: result.usage ?? null,
1402
1517
  });
1403
1518
  } catch (err) {
1404
1519
  console.error('[bridge] error:', err.message);
@@ -1416,7 +1531,7 @@ function resultHandler(req, res) {
1416
1531
  text: 'That task is no longer available — the bridge may have restarted.',
1417
1532
  });
1418
1533
  }
1419
- res.json({ status: job.status, text: job.text, sessionReset: !!job.sessionReset });
1534
+ res.json({ status: job.status, text: job.text, sessionReset: !!job.sessionReset, usage: job.usage ?? null });
1420
1535
  }
1421
1536
 
1422
1537
  // Single-flight wrapper for reviving the saved preview: GET /preview and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.6.14",
3
+ "version": "0.6.15",
4
4
  "description": "Desktop-side receiver for the Esque Agent mobile app. Pairs your phone with a local coding-agent CLI (Claude Code, Codex, 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"