esque-bridge 0.2.0 → 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.
- package/index.js +109 -6
- 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
|
-
|
|
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;
|
|
@@ -138,7 +141,20 @@ const ADAPTERS = {
|
|
|
138
141
|
// `--output-format json` returns a single JSON object with
|
|
139
142
|
// {result, session_id, is_error, ...} — easy to parse, exact.
|
|
140
143
|
buildArgs(_prompt, prevSessionId) {
|
|
141
|
-
|
|
144
|
+
// `--dangerously-skip-permissions` lets Claude actually use its
|
|
145
|
+
// Write / Edit / Bash tools without an interactive approval prompt.
|
|
146
|
+
// In headless `--print` mode there's no way to say "yes" to a
|
|
147
|
+
// permission request, so without this every file write is silently
|
|
148
|
+
// blocked — the agent looks busy but can't touch the disk. This is
|
|
149
|
+
// the autonomous-bridge use case the flag exists for; access is
|
|
150
|
+
// already gated by the per-session pairing secret. (Mirrors Aider's
|
|
151
|
+
// `--yes-always` below.)
|
|
152
|
+
const args = [
|
|
153
|
+
'--print',
|
|
154
|
+
'--output-format',
|
|
155
|
+
'json',
|
|
156
|
+
'--dangerously-skip-permissions',
|
|
157
|
+
];
|
|
142
158
|
if (prevSessionId) args.push('--resume', prevSessionId);
|
|
143
159
|
return args;
|
|
144
160
|
},
|
|
@@ -357,6 +373,26 @@ function requireAuth(req, res, next) {
|
|
|
357
373
|
next();
|
|
358
374
|
}
|
|
359
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
|
+
|
|
360
396
|
async function executeHandler(req, res) {
|
|
361
397
|
const body = req.body || {};
|
|
362
398
|
const prompt = String(body.prompt || '');
|
|
@@ -371,22 +407,89 @@ async function executeHandler(req, res) {
|
|
|
371
407
|
`[bridge] POST ${preview}… esque=${esqueSessionId ?? '-'} ${AGENT_TYPE}=${prev ?? 'new'}`,
|
|
372
408
|
);
|
|
373
409
|
|
|
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.
|
|
446
|
+
res.status(200);
|
|
447
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
448
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
449
|
+
const heartbeat = setInterval(() => {
|
|
450
|
+
try {
|
|
451
|
+
res.write(' ');
|
|
452
|
+
} catch {
|
|
453
|
+
/* socket already closed */
|
|
454
|
+
}
|
|
455
|
+
}, 5000);
|
|
456
|
+
const finish = (payload) => {
|
|
457
|
+
clearInterval(heartbeat);
|
|
458
|
+
try {
|
|
459
|
+
res.end(JSON.stringify(payload));
|
|
460
|
+
} catch {
|
|
461
|
+
/* socket already closed */
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
374
465
|
try {
|
|
375
466
|
const result = await runAgent(prompt, esqueSessionId);
|
|
376
|
-
|
|
467
|
+
finish({
|
|
377
468
|
text: result.text,
|
|
378
469
|
status: result.isError ? 'blocked' : 'finished',
|
|
379
470
|
});
|
|
380
471
|
} catch (err) {
|
|
381
472
|
console.error('[bridge] error:', err.message);
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
473
|
+
finish({ text: `${adapter.label} failed: ${err.message}`, status: 'blocked' });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
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
|
+
});
|
|
385
486
|
}
|
|
487
|
+
res.json({ status: job.status, text: job.text });
|
|
386
488
|
}
|
|
387
489
|
|
|
388
490
|
app.post('/execute', requireAuth, executeHandler);
|
|
389
491
|
app.post('/', requireAuth, executeHandler);
|
|
492
|
+
app.get('/result/:id', requireAuth, resultHandler);
|
|
390
493
|
app.use((_req, res) => res.status(405).json({ error: 'POST only' }));
|
|
391
494
|
|
|
392
495
|
// --- Boot -----------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "esque-bridge",
|
|
3
|
-
"version": "0.2.
|
|
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"
|