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.
- package/index.js +134 -19
- 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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|