esque-bridge 0.6.13 → 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 +195 -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;
@@ -191,6 +194,47 @@ function clearCliSessionId(agent, esqueSessionId) {
191
194
  saveSessions();
192
195
  }
193
196
 
197
+ // --- Handoff log ----------------------------------------------------------
198
+ // A running, on-disk record of each turn (prompt + what the agent did), kept in
199
+ // the project folder. Its whole purpose is to survive a lost CLI session: if
200
+ // the agent's conversation memory is ever GC'd, the fresh session is told to
201
+ // read this file and recover the project's context — so a memory reset doesn't
202
+ // mean starting from zero. Best-effort; bounded so it stays readable.
203
+ const HANDOFF_MAX = 48 * 1024;
204
+ function handoffRel(esqueSessionId) {
205
+ const short = String(esqueSessionId || 'default').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 16) || 'default';
206
+ return `.esque/handoff-${short}.md`;
207
+ }
208
+ function handoffPath(esqueSessionId) {
209
+ return path.join(WORKDIR, handoffRel(esqueSessionId));
210
+ }
211
+ function appendHandoff(esqueSessionId, prompt, replyText) {
212
+ if (!esqueSessionId) return;
213
+ try {
214
+ fs.mkdirSync(path.join(WORKDIR, '.esque'), { recursive: true });
215
+ const file = handoffPath(esqueSessionId);
216
+ const header = fs.existsSync(file)
217
+ ? ''
218
+ : "# Esque handoff log\n\n> A running record of this project's work so your AI agent can recover context if its session is ever reset. Safe to delete (or add `.esque/` to .gitignore).\n\n";
219
+ const reply = String(replyText || '').trim().replace(/\n{3,}/g, '\n\n').slice(0, 1200);
220
+ const stamp = new Date().toISOString();
221
+ fs.appendFileSync(
222
+ file,
223
+ `${header}## ${stamp}\n\n**Prompt:** ${String(prompt || '').slice(0, 600)}\n\n**Agent:** ${reply || '(no output)'}\n\n---\n\n`,
224
+ );
225
+ // Keep it bounded: on overflow, retain the most recent entries (trim to a
226
+ // clean entry boundary so the recovered context reads cleanly).
227
+ const buf = fs.readFileSync(file, 'utf8');
228
+ if (buf.length > HANDOFF_MAX) {
229
+ const tail = buf.slice(buf.length - HANDOFF_MAX);
230
+ const cut = tail.indexOf('\n## ');
231
+ fs.writeFileSync(file, '# Esque handoff log (older entries trimmed)\n\n' + (cut >= 0 ? tail.slice(cut + 1) : tail));
232
+ }
233
+ } catch {
234
+ /* best-effort — never let handoff bookkeeping break a turn */
235
+ }
236
+ }
237
+
194
238
  // --- Adapters -------------------------------------------------------------
195
239
  // Each adapter describes how to invoke a CLI for a single prompt. The
196
240
  // runner is identical across adapters; only argv-building and stdout
@@ -204,7 +248,7 @@ const ADAPTERS = {
204
248
  "npm install -g @anthropic-ai/claude-code, then \`claude /login\` to authenticate.",
205
249
  // `--output-format json` returns a single JSON object with
206
250
  // {result, session_id, is_error, ...} — easy to parse, exact.
207
- buildArgs(_prompt, prevSessionId) {
251
+ buildArgs(_prompt, prevSessionId, model) {
208
252
  // `--dangerously-skip-permissions` lets Claude actually use its
209
253
  // Write / Edit / Bash tools without an interactive approval prompt.
210
254
  // In headless `--print` mode there's no way to say "yes" to a
@@ -219,6 +263,9 @@ const ADAPTERS = {
219
263
  'json',
220
264
  '--dangerously-skip-permissions',
221
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);
222
269
  if (prevSessionId) args.push('--resume', prevSessionId);
223
270
  return args;
224
271
  },
@@ -229,9 +276,22 @@ const ADAPTERS = {
229
276
  text: r.result || r.text || '(claude returned no text)',
230
277
  cliSessionId: r.session_id || null,
231
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
+ },
232
292
  };
233
293
  } catch {
234
- return { text: stdout.trim() || '(no output)', cliSessionId: null, isError: false };
294
+ return { text: stdout.trim() || '(no output)', cliSessionId: null, isError: false, usage: null };
235
295
  }
236
296
  },
237
297
  },
@@ -248,12 +308,14 @@ const ADAPTERS = {
248
308
  // looks busy but can't touch the disk. Access is already gated by the
249
309
  // pairing secret + the startup workdir confirmation. --skip-git-repo-check
250
310
  // lets it run in a brand-new (not-yet-git) project dir for `fresh` builds.
251
- buildArgs(_prompt, _prevSessionId) {
252
- return [
311
+ buildArgs(_prompt, _prevSessionId, model) {
312
+ const args = [
253
313
  'exec',
254
314
  '--dangerously-bypass-approvals-and-sandbox',
255
315
  '--skip-git-repo-check',
256
316
  ];
317
+ if (model) args.push('--model', model); // optional per-agent model pin
318
+ return args;
257
319
  },
258
320
  parseOutput(stdout) {
259
321
  // `codex exec` streams its run to stdout and logs to stderr; the trimmed
@@ -263,6 +325,13 @@ const ADAPTERS = {
263
325
  text: stdout.trim() || '(codex returned no output)',
264
326
  cliSessionId: null,
265
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,
266
335
  };
267
336
  },
268
337
  },
@@ -280,22 +349,45 @@ const ADAPTERS = {
280
349
  // "--message - = read stdin" convention; passing '-' would send the
281
350
  // agent a literal one-character message.
282
351
  promptInArgs: true,
283
- buildArgs(prompt) {
284
- return [
352
+ buildArgs(prompt, _prevSessionId, model) {
353
+ const args = [
285
354
  '--message', prompt,
286
355
  '--no-stream',
287
356
  '--yes-always', // skip the "apply edit? y/n" prompts
288
357
  '--no-pretty', // ANSI-free output for parsing
289
358
  '--no-show-model-warnings',
290
359
  ];
360
+ if (model) args.push('--model', model); // optional per-agent model pin
361
+ return args;
291
362
  },
292
363
  parseOutput(stdout) {
293
364
  // Aider streams its assistant turn interleaved with diff blocks.
294
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;
295
386
  return {
296
387
  text: stdout.trim() || '(aider returned no text)',
297
388
  cliSessionId: null,
298
389
  isError: false,
390
+ usage,
299
391
  };
300
392
  },
301
393
  },
@@ -352,12 +444,12 @@ if (AGENT_TYPE === 'custom') {
352
444
  // editing files after the bridge is gone.
353
445
  let activeAgentKill = null;
354
446
 
355
- function runAgent(prompt, esqueSessionId) {
447
+ function runAgent(prompt, esqueSessionId, model) {
356
448
  return new Promise((resolve, reject) => {
357
449
  const prevId = getCliSessionId(AGENT_TYPE, esqueSessionId);
358
450
  let argv;
359
451
  try {
360
- argv = adapter.buildArgs(prompt, prevId);
452
+ argv = adapter.buildArgs(prompt, prevId, model);
361
453
  } catch (err) {
362
454
  reject(err);
363
455
  return;
@@ -534,15 +626,31 @@ function runAgent(prompt, esqueSessionId) {
534
626
  // bricked forever. Detect that specific failure, drop the mapping, and rerun
535
627
  // once as a fresh CLI session.
536
628
  const RESUME_FAIL_RE = /no conversation found|session.*not found|unknown session|invalid session/i;
537
- async function runAgentResilient(prompt, esqueSessionId) {
629
+ async function runAgentResilient(prompt, esqueSessionId, model) {
538
630
  const prevId = getCliSessionId(AGENT_TYPE, esqueSessionId);
539
631
  try {
540
- return await runAgent(prompt, esqueSessionId);
632
+ return await runAgent(prompt, esqueSessionId, model);
541
633
  } catch (err) {
542
634
  if (prevId && RESUME_FAIL_RE.test(String(err && err.message))) {
543
635
  console.warn('[bridge] stored CLI session is gone — retrying as a fresh session');
544
636
  clearCliSessionId(AGENT_TYPE, esqueSessionId);
545
- return runAgent(prompt, esqueSessionId);
637
+ // The agent lost its memory. If we've been keeping a handoff log, point
638
+ // the fresh session at it FIRST so it recovers the project's context
639
+ // instead of starting from zero.
640
+ let recovered = prompt;
641
+ try {
642
+ if (fs.existsSync(handoffPath(esqueSessionId))) {
643
+ recovered =
644
+ `[Esque session recovery] Your previous conversation in this project was reset and its in-memory context was lost. ` +
645
+ `BEFORE anything else, read the file \`${handoffRel(esqueSessionId)}\` in this folder — a running log of everything done in this project so far — and use it to reconstruct context. Then carry out this request:\n\n${prompt}`;
646
+ }
647
+ } catch {
648
+ /* fall back to the bare prompt */
649
+ }
650
+ const fresh = await runAgent(recovered, esqueSessionId, model);
651
+ // Tell the phone the agent's memory was reset (it ignores the field if
652
+ // unknown). Older bridges never set it.
653
+ return { ...fresh, sessionReset: true };
546
654
  }
547
655
  throw err;
548
656
  }
@@ -798,6 +906,45 @@ function waitForPort(port, timeoutMs) {
798
906
  });
799
907
  }
800
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
+
801
948
  function hasCloudflared() {
802
949
  try { execSync(isWindows ? 'where cloudflared' : 'which cloudflared', { stdio: 'ignore' }); return true; }
803
950
  catch { return false; }
@@ -909,6 +1056,15 @@ async function probeBuildError(port) {
909
1056
  if (!root) return null; // couldn't reach it — don't cry wolf
910
1057
  if (root.status >= 500) return summarizeBuildError(root.status, root.body);
911
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
+
912
1068
  // Expo/Metro: the HTML points at a *.bundle script. Fetching it forces the
913
1069
  // compile and surfaces the react-native-web class of error as a 500.
914
1070
  const m = root.body.match(/<script[^>]+src="([^"]*\.bundle[^"]*)"/i);
@@ -1054,6 +1210,16 @@ async function startPreview(cmd, port) {
1054
1210
  console.error(`[preview] dependency install failed:\n${deps.error}`);
1055
1211
  return { url: null, buildError: deps.error };
1056
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
+ }
1057
1223
  console.log(`[preview] starting: ${cmd} (port ${port})`);
1058
1224
  // Remember the command now (not on success): a revival attempt after a
1059
1225
  // mid-start crash should still know what to run.
@@ -1154,8 +1320,8 @@ async function applyPreview(text) {
1154
1320
  return { text: text.replace(PREVIEW_RE, () => replacement), buildError };
1155
1321
  }
1156
1322
 
1157
- async function runAgentWithPreview(prompt, sessionId) {
1158
- const result = await runAgentResilient(prompt, sessionId);
1323
+ async function runAgentWithPreview(prompt, sessionId, model) {
1324
+ const result = await runAgentResilient(prompt, sessionId, model);
1159
1325
  if (!result || !result.text) return result;
1160
1326
 
1161
1327
  const applied = await applyPreview(result.text);
@@ -1174,7 +1340,7 @@ async function runAgentWithPreview(prompt, sessionId) {
1174
1340
  `Fix the root cause in the code, then re-emit the ESQUE_PREVIEW marker on ` +
1175
1341
  `its own line. Keep the explanation brief — make the fix and output the marker.`;
1176
1342
  let fix = null;
1177
- 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); }
1178
1344
  if (fix && fix.text) {
1179
1345
  const fixApplied = await applyPreview(fix.text);
1180
1346
  result.text = `${applied.text}\n\n— Auto-fix attempt —\n${fixApplied.text}`;
@@ -1248,8 +1414,8 @@ async function sendExpoPush(token, kind, sessionId, title, message) {
1248
1414
  // races two --resume's of the same CLI session, and collides in the preview
1249
1415
  // pipeline. A simple promise chain keeps arrival order and isolates failures.
1250
1416
  let runQueue = Promise.resolve();
1251
- function enqueueRun(prompt, sessionId) {
1252
- const run = runQueue.then(() => runAgentWithPreview(prompt, sessionId));
1417
+ function enqueueRun(prompt, sessionId, model) {
1418
+ const run = runQueue.then(() => runAgentWithPreview(prompt, sessionId, model));
1253
1419
  runQueue = run.then(() => undefined, () => undefined);
1254
1420
  return run;
1255
1421
  }
@@ -1259,6 +1425,10 @@ async function executeHandler(req, res) {
1259
1425
  const prompt = String(body.prompt || '');
1260
1426
  const esqueSessionId = body.sessionId ?? null;
1261
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;
1262
1432
  if (!prompt.trim()) {
1263
1433
  return res.status(400).json({ text: 'Empty prompt.', status: 'blocked' });
1264
1434
  }
@@ -1279,16 +1449,19 @@ async function executeHandler(req, res) {
1279
1449
  const jobId = newJobId();
1280
1450
  jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
1281
1451
  res.json({ jobId, status: 'working' });
1282
- enqueueRun(prompt, esqueSessionId)
1452
+ enqueueRun(prompt, esqueSessionId, model)
1283
1453
  .then((result) => {
1284
1454
  jobs.set(jobId, {
1285
1455
  status: result.isError ? 'blocked' : 'finished',
1286
1456
  text: result.text,
1457
+ sessionReset: !!result.sessionReset,
1458
+ usage: result.usage ?? null,
1287
1459
  createdAt: Date.now(),
1288
1460
  });
1289
1461
  console.log(
1290
1462
  `[bridge] done job=${jobId} ${result.isError ? 'blocked' : 'finished'}`,
1291
1463
  );
1464
+ if (!result.isError) appendHandoff(esqueSessionId, prompt, result.text);
1292
1465
  const ok = !result.isError;
1293
1466
  sendExpoPush(
1294
1467
  pushToken,
@@ -1334,10 +1507,13 @@ async function executeHandler(req, res) {
1334
1507
  };
1335
1508
 
1336
1509
  try {
1337
- const result = await enqueueRun(prompt, esqueSessionId);
1510
+ const result = await enqueueRun(prompt, esqueSessionId, model);
1511
+ if (!result.isError) appendHandoff(esqueSessionId, prompt, result.text);
1338
1512
  finish({
1339
1513
  text: result.text,
1340
1514
  status: result.isError ? 'blocked' : 'finished',
1515
+ sessionReset: !!result.sessionReset,
1516
+ usage: result.usage ?? null,
1341
1517
  });
1342
1518
  } catch (err) {
1343
1519
  console.error('[bridge] error:', err.message);
@@ -1355,7 +1531,7 @@ function resultHandler(req, res) {
1355
1531
  text: 'That task is no longer available — the bridge may have restarted.',
1356
1532
  });
1357
1533
  }
1358
- res.json({ status: job.status, text: job.text });
1534
+ res.json({ status: job.status, text: job.text, sessionReset: !!job.sessionReset, usage: job.usage ?? null });
1359
1535
  }
1360
1536
 
1361
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.13",
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"