esque-bridge 0.2.1 → 0.2.2

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 +74 -9
  2. package/package.json +1 -1
package/index.js CHANGED
@@ -71,7 +71,10 @@ const PORT = Number(argv.port || process.env.PORT || 3030);
71
71
  const WORKDIR = path.resolve(
72
72
  argv.workdir || process.env.CLAUDE_WORKDIR || process.cwd(),
73
73
  );
74
- const TIMEOUT_MS = Number(argv.timeout || 5 * 60 * 1000);
74
+ // 20 min default. With the async protocol the phone no longer holds a
75
+ // connection open, so a generous ceiling just lets big agentic tasks (full
76
+ // scaffolds) finish. Override with --timeout.
77
+ const TIMEOUT_MS = Number(argv.timeout || 20 * 60 * 1000);
75
78
  const AGENT_TYPE = String(argv.agent || process.env.ESQUE_AGENT || 'claude').toLowerCase();
76
79
  const CUSTOM_CMD = argv.cmd || process.env.ESQUE_CMD || null;
77
80
  const BIN_OVERRIDE = argv.bin || null;
@@ -370,6 +373,26 @@ function requireAuth(req, res, next) {
370
373
  next();
371
374
  }
372
375
 
376
+ // ── Async job store ───────────────────────────────────────────────────
377
+ // A prompt can run for minutes (e.g. a full project scaffold). Holding one
378
+ // HTTP connection open that long is fragile over tunnels and times the
379
+ // phone out. Newer phones advertise `x-esque-async`; they get a jobId back
380
+ // immediately and poll GET /result/:id while the agent runs in the
381
+ // background. Jobs are evicted after a TTL so the map can't grow unbounded.
382
+ const jobs = new Map(); // jobId -> { status, text, createdAt }
383
+ const RESULT_TTL_MS = 30 * 60 * 1000;
384
+ let jobSeq = 0;
385
+ function newJobId() {
386
+ jobSeq += 1;
387
+ return `j${Date.now().toString(36)}${jobSeq.toString(36)}`;
388
+ }
389
+ function gcJobs() {
390
+ const now = Date.now();
391
+ for (const [id, j] of jobs) {
392
+ if (now - j.createdAt > RESULT_TTL_MS) jobs.delete(id);
393
+ }
394
+ }
395
+
373
396
  async function executeHandler(req, res) {
374
397
  const body = req.body || {};
375
398
  const prompt = String(body.prompt || '');
@@ -384,12 +407,42 @@ async function executeHandler(req, res) {
384
407
  `[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} ${AGENT_TYPE}=${prev ?? 'new'}`,
385
408
  );
386
409
 
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.
410
+ // ── Async path (polling-capable phones) ─────────────────────────────
411
+ // Return a jobId instantly and run the agent in the background. The phone
412
+ // polls GET /result/:id every few seconds tiny requests that survive
413
+ // any tunnel and never time out, no matter how long the task runs.
414
+ const wantsAsync = req.header('x-esque-async') === '1' || body.async === true;
415
+ if (wantsAsync) {
416
+ gcJobs();
417
+ const jobId = newJobId();
418
+ jobs.set(jobId, { status: 'working', text: '', createdAt: Date.now() });
419
+ res.json({ jobId, status: 'working' });
420
+ runAgent(prompt, esqueSessionId)
421
+ .then((result) => {
422
+ jobs.set(jobId, {
423
+ status: result.isError ? 'blocked' : 'finished',
424
+ text: result.text,
425
+ createdAt: Date.now(),
426
+ });
427
+ console.log(
428
+ `[bridge] done job=${jobId} ${result.isError ? 'blocked' : 'finished'}`,
429
+ );
430
+ })
431
+ .catch((err) => {
432
+ jobs.set(jobId, {
433
+ status: 'blocked',
434
+ text: `${adapter.label} failed: ${err.message}`,
435
+ createdAt: Date.now(),
436
+ });
437
+ console.error(`[bridge] error job=${jobId}:`, err.message);
438
+ });
439
+ return;
440
+ }
441
+
442
+ // ── Legacy synchronous path (older phones) ──────────────────────────
443
+ // Hold the connection open with a keep-alive heartbeat so idle-closing
444
+ // proxies don't drop it while the agent thinks. The phone trims the body
445
+ // before JSON.parse, so the leading whitespace is harmless.
393
446
  res.status(200);
394
447
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
395
448
  res.setHeader('Cache-Control', 'no-cache, no-transform');
@@ -417,14 +470,26 @@ async function executeHandler(req, res) {
417
470
  });
418
471
  } catch (err) {
419
472
  console.error('[bridge] error:', err.message);
420
- // Headers are already sent (heartbeat), so the error rides in the body
421
- // with status 'blocked' rather than an HTTP 500.
422
473
  finish({ text: `${adapter.label} failed: ${err.message}`, status: 'blocked' });
423
474
  }
424
475
  }
425
476
 
477
+ // Poll endpoint for the async protocol. Returns the job's current state:
478
+ // { status: 'working' | 'finished' | 'blocked', text }.
479
+ function resultHandler(req, res) {
480
+ const job = jobs.get(req.params.id);
481
+ if (!job) {
482
+ return res.status(404).json({
483
+ status: 'blocked',
484
+ text: 'That task is no longer available — the bridge may have restarted.',
485
+ });
486
+ }
487
+ res.json({ status: job.status, text: job.text });
488
+ }
489
+
426
490
  app.post('/execute', requireAuth, executeHandler);
427
491
  app.post('/', requireAuth, executeHandler);
492
+ app.get('/result/:id', requireAuth, resultHandler);
428
493
  app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
429
494
 
430
495
  // --- Boot -----------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esque-bridge",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
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"