-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
1344 lines (1209 loc) · 52.4 KB
/
Copy pathserver.js
File metadata and controls
1344 lines (1209 loc) · 52.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const AUTH_USER = process.env.HUMANAIE_USER || "";
const AUTH_PASS = process.env.HUMANAIE_PASS || "";
// DATA_DIR — base directory for sessions, history, and data files
const DATA_DIR = process.env.HUMANAIE_DATA_DIR || process.cwd();
process.env.PLAYWRIGHT_BROWSERS_PATH = process.env.PLAYWRIGHT_BROWSERS_PATH || `${DATA_DIR}/browsers`;
const express = require('express');
// Stealth stack: playwright-extra wraps standard playwright + the
// puppeteer-extra stealth plugin patches the JS fingerprint surface
// (navigator.webdriver, plugin spoofing, WebGL, Permissions, Chrome runtime).
const { chromium } = require('playwright-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
chromium.use(StealthPlugin());
const path = require('path');
const fs = require('fs');
const { execFile, execFileSync } = require('child_process');
// ── Human takeover flag ──────────────────────────────────────────────────────
let humanControl = false;
// ── Global crash guards — keep the process alive no matter what ───────────────
process.on('uncaughtException', (err) => {
console.error('[FATAL] Uncaught exception:', err.message, err.stack);
// Don't exit — log and continue
});
process.on('unhandledRejection', (reason) => {
console.error('[FATAL] Unhandled rejection:', reason);
// Don't exit — log and continue
});
const _pkg = JSON.parse(fs.readFileSync(require('path').join(__dirname, 'package.json'), 'utf-8'));
const APP_VERSION = _pkg.version || '0.0.0';
const APP_NAME = _pkg.name || 'humanaie';
// Git build info — captured at module load so the running server reports
// exactly what's checked out. The frontend reads /version to surface this
// on the HANDROID tab, which is the quickest way to verify "did my pull
// land before the browser reload".
let APP_GIT_SHA = '';
let APP_GIT_DIRTY = false;
try {
APP_GIT_SHA = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
cwd: __dirname, timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'],
}).toString().trim();
} catch {}
try {
const dirty = execFileSync('git', ['status', '--porcelain'], {
cwd: __dirname, timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'],
}).toString().trim();
APP_GIT_DIRTY = dirty.length > 0;
} catch {}
const app = express();
app.use(express.json());
// HTTP Basic Auth (skip for localhost and SSE endpoint)
const AUTH_ENABLED = AUTH_USER && AUTH_PASS;
app.use((req, res, next) => {
const ip = req.ip || req.connection.remoteAddress || '';
const isLocal = ip === '127.0.0.1' || ip === '::1' || ip.endsWith('::ffff:127.0.0.1');
const isMedia = req.path.match(/^\/sessions\/.+\/(mp4|edited\/.+|thumbnail)$/);
if (isLocal || req.path === '/events' || req.path === '/stream' || req.path === '/android/stream' || isMedia) return next();
if (!AUTH_ENABLED) return next();
const auth = req.headers["authorization"];
if (!auth || !auth.startsWith('Basic ')) {
res.setHeader('WWW-Authenticate', 'Basic realm="HumanAIE"');
return res.status(401).send('Authentication required');
}
const [user, pass] = Buffer.from(auth.slice(6), 'base64').toString().split(':');
if (user === AUTH_USER && pass === AUTH_PASS) return next();
res.setHeader('WWW-Authenticate', 'Basic realm="HumanAIE"');
return res.status(401).send('Invalid credentials');
});
app.use(express.static(path.join(__dirname, 'public')));
const android = require('./android');
app.use('/android', android.router);
// Wire pushAction (hoisted function declaration below) so android.js can
// broadcast tap/swipe events over the existing /events SSE pipeline.
android.setBroadcaster(pushAction);
const teach = require('./teach');
teach.configure({ rootDir: `${DATA_DIR}/humanaie-sessions` });
const WORKFLOWS_DIR = `${DATA_DIR}/workflows`;
teach.configureWorkflows({ rootDir: WORKFLOWS_DIR });
app.use('/', teach.router);
console.log(android.ADB_AVAILABLE
? `[android] ADB found at ${android.adbPath}, phone target: ${android.PHONE_ADDR || '(not configured — set HUMANAIE_PHONE_IP)'}`
: `[android] ADB not found — /android/* will return 503`);
let browser, context;
let tabs = []; // [{ id, page }]
let activeTabId = null;
let tabCounter = 0;
let page = null; // always mirrors the active tab's page
function getTabList() {
return tabs.map(t => ({
id: t.id,
url: (() => { try { return t.page.url(); } catch(e) { return 'about:blank'; } })(),
active: t.id === activeTabId,
}));
}
function switchActiveTab(id) {
const tab = tabs.find(t => t.id === id);
if (!tab) return false;
activeTabId = id;
page = tab.page;
pushAction('Tab', `Switched to tab ${id}`, 'ok', { tabs: getTabList() });
return true;
}
async function createTab(url = 'about:blank') {
const id = ++tabCounter;
const p = await context.newPage();
if (url !== 'about:blank') {
try { await p.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch(e) {}
}
tabs.push({ id, page: p });
switchActiveTab(id);
pushAction('New Tab', `Tab ${id}: ${url}`, 'ok', { tabs: getTabList() });
return id;
}
async function closeTab(id) {
const idx = tabs.findIndex(t => t.id === id);
if (idx === -1 || tabs.length <= 1) return false;
const tab = tabs[idx];
try { await tab.page.close(); } catch(e) {}
tabs.splice(idx, 1);
if (activeTabId === id) switchActiveTab(tabs[Math.max(0, idx - 1)].id);
pushAction('Close Tab', `Tab ${id} closed`, 'ok', { tabs: getTabList() });
return true;
}
// ── Frame cache + MJPEG stream ────────────────────────────────────────────────
let latestFrame = null; // latest captured JPEG buffer — always in memory
const streamClients = []; // active MJPEG stream response objects
function pushStreamFrame(buf) {
latestFrame = buf;
if (!streamClients.length) return;
const header = `--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${buf.length}\r\n\r\n`;
for (let i = streamClients.length - 1; i >= 0; i--) {
try {
streamClients[i].write(header);
streamClients[i].write(buf);
streamClients[i].write('\r\n');
} catch(e) {
streamClients.splice(i, 1);
}
}
}
// ── Action log + SSE ─────────────────────────────────────────────────────────
const actionLog = [];
const sseClients = [];
function pushAction(type, detail, status = 'ok', extra = {}) {
const entry = { time: new Date().toISOString(), type, detail, status, ...extra };
actionLog.push(entry);
if (actionLog.length > 200) actionLog.shift();
const data = `data: ${JSON.stringify(entry)}\n\n`;
sseClients.forEach(r => r.write(data));
console.log(`[${status.toUpperCase()}] ${type}: ${detail}`);
}
// ── Calibration target (mobile web page captures real touch coords) ─────────
// The page at /calibrate-target loads on the phone's Chrome; every touch it
// receives gets POSTed back to /calibrate-target/report. The cam UI and the
// /calibrate/start orchestrator pair each fired /android/tap with the
// corresponding report to measure end-to-end click drift.
const CALIB_REPORTS = [];
const CALIB_MAX = 200;
app.get('/calibrate-target', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'calibrate-target.html'));
});
app.post('/calibrate-target/report', (req, res) => {
const body = req.body || {};
if (typeof body.phoneX !== 'number' || typeof body.phoneY !== 'number') {
return res.status(400).json({ error: 'phoneX/phoneY required' });
}
const report = {
phoneX: body.phoneX, phoneY: body.phoneY,
clientX: body.clientX, clientY: body.clientY,
innerW: body.innerW, innerH: body.innerH,
screenW: body.screenW, screenH: body.screenH,
dpr: body.dpr, t: body.t || Date.now(),
};
CALIB_REPORTS.push(report);
if (CALIB_REPORTS.length > CALIB_MAX) CALIB_REPORTS.shift();
try {
pushAction('CalibrationReport', `(${Math.round(report.phoneX)}, ${Math.round(report.phoneY)})`, 'ok', {
phoneX: report.phoneX, phoneY: report.phoneY, t: report.t,
});
} catch {}
res.json({ ok: true });
});
app.get('/calibrate-target/reports', (req, res) => {
res.json({ reports: CALIB_REPORTS.slice(-50) });
});
// Ready beacon — the calibration page POSTs here when it has loaded and is
// listening for touches, along with its current viewport dims. The
// orchestrator uses both: the timestamp gates firing, and the dims let
// it pick tap targets inside the visible page area (avoiding chrome at
// the top or nav-bar at the bottom).
let calibReadyAt = 0;
let calibPageDims = null; // { innerW, innerH, screenW, screenH, dpr, topOffsetCssPx, leftOffsetCssPx }
app.post('/calibrate-target/ready', (req, res) => {
calibReadyAt = Date.now();
if (req.body && typeof req.body.innerH === 'number') calibPageDims = req.body;
res.json({ ok: true, t: calibReadyAt });
});
app.get('/calibrate-target/ready', (req, res) => {
res.json({
ready_at: calibReadyAt,
age_ms: calibReadyAt ? Date.now() - calibReadyAt : null,
dims: calibPageDims,
});
});
// Clear tick — the cam UI / orchestrator POSTs here right before firing
// calibration taps. The phone-side page polls /calibrate-target/clear-tick
// and wipes its accumulated dots when the tick changes, so each run shows
// only the current run's dots (no cross-run number confusion).
let calibClearTick = 0;
app.post('/calibrate-target/clear', (req, res) => {
calibClearTick++;
CALIB_REPORTS.length = 0;
res.json({ ok: true, tick: calibClearTick });
});
app.get('/calibrate-target/clear-tick', (req, res) => {
res.json({ tick: calibClearTick });
});
// Aim trainer results — the calibration page POSTs here when a trainer
// session finishes. Stored as the most-recent result; broadcast via SSE
// so the cam UI (or an AI watching /events) can react.
let calibAimResult = null;
app.post('/calibrate-target/aim-result', (req, res) => {
calibAimResult = req.body || null;
try {
if (calibAimResult) {
pushAction('AimTrainer',
`${calibAimResult.hits}/${calibAimResult.N} in ${(calibAimResult.totalSeconds || 0).toFixed(2)}s · drift ${(calibAimResult.avgDriftPhonePx || 0).toFixed(1)}px`,
'ok', { aim: calibAimResult });
}
} catch {}
res.json({ ok: true });
});
app.get('/calibrate-target/aim-result', (req, res) => {
res.json({ result: calibAimResult });
});
// One-shot calibration orchestrator. AI agents call this to verify click
// accuracy before driving the phone. Opens the target page on the phone,
// fires 9 taps at known coords, waits for reports, returns drift summary.
// Verdict: accurate (avg<5px) / minor-offset (<30) / misaligned (≥30) /
// incomplete (<half reports).
app.post('/calibrate/start', async (req, res) => {
try {
const port = process.env.HUMANAIE_PORT || process.env.PORT || '3333';
// Self-fetches use loopback (fast, no DNS).
const loopback = 'http://127.0.0.1:' + port;
// The URL we hand the PHONE must be reachable from the phone — loopback
// would resolve to the phone itself. Prefer an explicit override, else
// the Host header from the caller (works when called from the LAN UI),
// else autodetect the first non-loopback IPv4 interface.
const explicit = process.env.HUMANAIE_LAN_HOST;
const fromHeader = req.headers.host && !/^(127\.|localhost|::1|\[?::1\]?)/.test(req.headers.host) ? req.headers.host : null;
let lanHost = explicit || fromHeader;
if (!lanHost) {
const ifaces = require('os').networkInterfaces();
outer: for (const name of Object.keys(ifaces)) {
for (const i of ifaces[name]) {
if (i.family === 'IPv4' && !i.internal) { lanHost = i.address + ':' + port; break outer; }
}
}
}
if (!lanHost) return res.status(500).json({ error: 'could not determine LAN host (set HUMANAIE_LAN_HOST)' });
const phoneTargetUrl = 'http://' + lanHost + '/calibrate-target';
const status = await fetch(loopback + '/android/status').then(r => r.json()).catch(() => null);
if (!status || !status.screen_w || !status.screen_h) {
return res.status(503).json({ error: 'phone not connected (no screen_w/h)' });
}
const W = status.screen_w, H = status.screen_h;
const positions = [];
for (const yf of [0.1, 0.5, 0.9]) {
for (const xf of [0.1, 0.5, 0.9]) {
positions.push({ px: Math.round(W * xf), py: Math.round(H * yf) });
}
}
CALIB_REPORTS.length = 0;
// Snapshot ready timestamp BEFORE opening so we can detect a fresh beacon.
const readyBefore = calibReadyAt;
try {
await fetch(loopback + '/android/open-url', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ url: phoneTargetUrl }),
});
} catch {}
// Wait up to 12s for the page to POST its ready beacon. Abort if
// it never arrives — firing taps into the user's open app is the
// worst possible failure mode.
const readyDeadline = Date.now() + 12000;
while (calibReadyAt === readyBefore && Date.now() < readyDeadline) {
await new Promise(r => setTimeout(r, 250));
}
if (calibReadyAt === readyBefore) {
return res.status(503).json({
error: 'calibration page never loaded on phone — check the LAN URL and that Chrome is the default browser. NO taps were fired.',
target_url: phoneTargetUrl,
});
}
// Brief settle so the page's touch listeners are fully attached.
await new Promise(r => setTimeout(r, 400));
for (const p of positions) {
try {
await fetch(base + '/android/tap', {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ x: p.px, y: p.py, source: 'agent-phone' }),
});
} catch {}
await new Promise(r => setTimeout(r, 800));
}
await new Promise(r => setTimeout(r, 1500)); // trailing reports
const reports = CALIB_REPORTS.slice();
const pairs = positions.map((target, j) => {
const obs = reports[j] || null;
if (!obs) return { target, observed: null, dx: null, dy: null, err: null };
const dx = obs.phoneX - target.px;
const dy = obs.phoneY - target.py;
return { target, observed: { x: obs.phoneX, y: obs.phoneY }, dx, dy, err: Math.hypot(dx, dy) };
});
const captured = pairs.filter(p => p.observed);
const avg = captured.length ? captured.reduce((s, p) => s + p.err, 0) / captured.length : null;
const max = captured.length ? Math.max(...captured.map(p => p.err)) : null;
let verdict;
if (captured.length < positions.length / 2) verdict = 'incomplete';
else if (avg < 5) verdict = 'accurate';
else if (avg < 30) verdict = 'minor-offset';
else verdict = 'misaligned';
res.json({ ok: true, captured: captured.length, total: positions.length, avg_err_px: avg, max_err_px: max, verdict, pairs });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// MJPEG stream — push-based live view, no polling needed
app.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'multipart/x-mixed-replace; boundary=frame');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
streamClients.push(res);
// Send latest cached frame immediately so viewer isn't blank
if (latestFrame) {
const header = `--frame\r\nContent-Type: image/jpeg\r\nContent-Length: ${latestFrame.length}\r\n\r\n`;
try { res.write(header); res.write(latestFrame); res.write('\r\n'); } catch(e) {}
}
req.on('close', () => {
const idx = streamClients.indexOf(res);
if (idx !== -1) streamClients.splice(idx, 1);
});
});
app.get('/events', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
sseClients.push(res);
actionLog.slice(-50).forEach(e => res.write(`data: ${JSON.stringify(e)}\n\n`));
req.on('close', () => {
const idx = sseClients.indexOf(res);
if (idx !== -1) sseClients.splice(idx, 1);
});
});
// ── Recording ────────────────────────────────────────────────────────────────
const SESSIONS_DIR = `${DATA_DIR}/humanaie-sessions`;
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
let recording = false;
let currentSession = null;
function startSession() {
const id = `session-${Date.now()}`;
const dir = path.join(SESSIONS_DIR, id);
fs.mkdirSync(dir, { recursive: true });
const vp = page ? page.viewportSize() : { width: 1280, height: 800 };
currentSession = { id, dir, frameIndex: 0, startTime: new Date().toISOString(), actions: [], hasActivity: false, width: vp ? vp.width : 1280, height: vp ? vp.height : 800 };
recording = true;
pushAction('Recording', 'Started', 'ok', { recording: true });
return id;
}
// isManual=true means user explicitly stopped — always keep.
// isManual=false means auto-rotate — purge if nothing happened.
function stopSession(isManual = false) {
if (!currentSession) return null;
recording = false;
const sess = currentSession;
currentSession = null;
// Auto-purge blank sessions (idle auto-rotations with no user actions)
if (!isManual && !sess.hasActivity) {
try { fs.rmSync(sess.dir, { recursive: true, force: true }); } catch(e) {}
pushAction('Purged', 'Blank session (no activity)', 'info', { recording: false });
return sess.id;
}
fs.writeFileSync(path.join(sess.dir, 'meta.json'), JSON.stringify({
id: sess.id, startTime: sess.startTime,
endTime: new Date().toISOString(),
frameCount: sess.frameIndex, actions: sess.actions,
width: sess.width || 1280, height: sess.height || 800,
}, null, 2));
pushAction('Recording', `Stopped — ${sess.frameIndex} frames`, 'ok', { recording: false });
generateOutputs(sess);
return sess.id;
}
// Prune oldest sessions when total size exceeds 10GB
const MAX_BYTES = 10 * 1024 * 1024 * 1024;
function dirSize(dir) {
let total = 0;
try {
for (const f of fs.readdirSync(dir)) {
const full = path.join(dir, f);
const st = fs.statSync(full);
total += st.isDirectory() ? dirSize(full) : st.size;
}
} catch (e) {}
return total;
}
function pruneOldSessions() {
try {
const all = fs.readdirSync(SESSIONS_DIR)
.filter(d => fs.existsSync(path.join(SESSIONS_DIR, d, 'meta.json')))
.map(d => {
const meta = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, d, 'meta.json'), 'utf8'));
return { id: d, startTime: meta.startTime };
})
.sort((a, b) => a.startTime.localeCompare(b.startTime)); // oldest first
let total = dirSize(SESSIONS_DIR);
for (const s of all) {
if (total <= MAX_BYTES) break;
const sDir = path.join(SESSIONS_DIR, s.id);
const sSize = dirSize(sDir);
fs.rmSync(sDir, { recursive: true, force: true });
total -= sSize;
console.log(`[CLEANUP] Deleted old session ${s.id} (freed ${(sSize/1024/1024).toFixed(1)}MB)`);
}
} catch (e) { /* ignore */ }
// Teach-session cleanup: drafts unpromoted after 7 days are deleted.
try {
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const now = Date.now();
const entries = fs.readdirSync(SESSIONS_DIR, { withFileTypes: true });
const wfList = teach.listWorkflows();
for (const ent of entries) {
if (!ent.isDirectory() || !ent.name.startsWith('teach-')) continue;
const dir = path.join(SESSIONS_DIR, ent.name);
const metaPath = path.join(dir, 'meta.json');
let m = null;
try { m = JSON.parse(fs.readFileSync(metaPath, 'utf-8')); } catch {}
const startedAt = (m && m.started_at) || 0;
const age = now - startedAt;
if (age > SEVEN_DAYS_MS) {
const referenced = wfList.some(w => w.source === ent.name);
if (!referenced) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
}
}
} catch (e) { /* sessions dir may not exist yet */ }
}
// Dead-time trimming: build ranges of frames worth keeping around real activity.
// Params tuned for 500ms-per-frame capture rate (2fps).
function getActivityRanges(actions, totalFrames) {
const BEFORE = 2; // frames to include before each action (~1s)
const AFTER = 4; // frames to include after each action (~2s)
const MERGE = 4; // merge ranges within this many frames (~2s)
const activityFrames = (actions || [])
.filter(a => a.label !== 'auto')
.map(a => a.frame)
.filter(f => f >= 0 && f < totalFrames);
if (!activityFrames.length) return null; // no activity — use all frames
// Build [start, end] ranges (inclusive)
let ranges = activityFrames.map(f => [
Math.max(0, f - BEFORE),
Math.min(totalFrames - 1, f + AFTER),
]);
// Sort by start frame
ranges.sort((a, b) => a[0] - b[0]);
// Merge overlapping / nearby ranges
const merged = [[...ranges[0]]];
for (let i = 1; i < ranges.length; i++) {
const last = merged[merged.length - 1];
if (ranges[i][0] <= last[1] + MERGE + 1) {
last[1] = Math.max(last[1], ranges[i][1]);
} else {
merged.push([...ranges[i]]);
}
}
return merged;
}
function generateOutputs(sess) {
const dir = sess.dir;
// Read frames from disk
const frames = fs.readdirSync(dir)
.filter(f => f.startsWith('frame-') && f.endsWith('.jpg'))
.sort();
if (!frames.length) return;
// Thumbnail: use first activity frame (or frame 0 as fallback)
try {
const firstActivity = (sess.actions || []).find(a => a.label !== 'auto');
const thumbIdx = firstActivity ? Math.max(0, firstActivity.frame - 1) : 0;
const thumbSrc = path.join(dir, frames[Math.min(thumbIdx, frames.length - 1)]);
fs.copyFileSync(thumbSrc, path.join(dir, 'thumbnail.jpg'));
} catch(e) {}
// Compute which frames to keep
const ranges = getActivityRanges(sess.actions, frames.length);
let ffmpegArgs, selectedCount;
const outputMp4 = path.join(dir, 'replay.mp4');
if (!ranges) {
// No activity recorded — keep everything (shouldn't happen due to purge, but safe)
selectedCount = frames.length;
// Build concat list even for all-frames case to avoid glob shell expansion
const concatAllPath = path.join(dir, 'concat.txt');
fs.writeFileSync(concatAllPath,
frames.map(f => `file '${path.join(dir, f)}'\nduration 0.25`).join('\n') + '\n'
);
ffmpegArgs = ['-y', '-f', 'concat', '-safe', '0', '-i', concatAllPath,
'-vf', 'scale=640:-2', '-c:v', 'libx264', '-crf', '23', '-pix_fmt', 'yuv420p', outputMp4];
} else {
// Build list of selected frames from ranges
const selected = [];
for (const [start, end] of ranges) {
for (let i = start; i <= end && i < frames.length; i++) selected.push(frames[i]);
}
selectedCount = selected.length;
// Write ffmpeg concat list (each frame shown for 0.5s = 2fps, matches capture rate)
const concatPath = path.join(dir, 'concat.txt');
fs.writeFileSync(concatPath,
selected.map(f => `file '${path.join(dir, f)}'\nduration 0.5`).join('\n') + '\n'
);
ffmpegArgs = ['-y', '-f', 'concat', '-safe', '0', '-i', concatPath,
'-vf', 'scale=640:-2', '-c:v', 'libx264', '-crf', '23', '-pix_fmt', 'yuv420p', outputMp4];
}
execFile('ffmpeg', ffmpegArgs, (err) => {
// Clean up concat list
try { fs.unlinkSync(path.join(dir, 'concat.txt')); } catch(e) {}
if (err) {
pushAction('MP4 failed', err.message.substring(0, 80), 'err');
} else {
// Delete raw frames — MP4 is the source of truth now
try {
frames.forEach(f => { try { fs.unlinkSync(path.join(dir, f)); } catch(e) {} });
} catch(e) {}
const mp4Size = (() => { try { return (fs.statSync(`${dir}/replay.mp4`).size / 1024 / 1024).toFixed(1) + 'MB'; } catch(e) { return '?'; } })();
const keptPct = Math.round((selectedCount / frames.length) * 100);
const trimMsg = ranges ? `${keptPct}% kept (dead time trimmed)` : 'all frames';
pushAction('MP4 ready', `${mp4Size} · ${trimMsg}`, 'ok', { sessionId: sess.id });
pruneOldSessions();
}
});
}
app.get('/version', (req, res) => {
res.json({
name: APP_NAME,
version: APP_VERSION,
git_sha: APP_GIT_SHA,
git_dirty: APP_GIT_DIRTY,
});
});
// ── Live status — public "browser is live" indicator ────────────────────────────
app.get('/live/status', (req, res) => {
var currentUrl = page ? page.url() : '';
res.json({
live: !!page && currentUrl !== 'about:blank',
url: currentUrl,
humanControl: humanControl || false
});
});
// ── Human takeover endpoints ─────────────────────────────────────────────────
app.post('/takeover', (req, res) => {
humanControl = true;
res.json({ success: true, humanControl: true });
});
app.post('/release', (req, res) => {
humanControl = false;
res.json({ success: true, humanControl: false });
});
// ── HumanAIE interaction endpoints (used by MCP tools in worker containers) ────
app.post('/live/click', async (req, res) => {
if (!page) return res.json({ success: false, error: 'No active page' });
try {
const { x, y } = req.body;
await page.mouse.click(x, y);
pushAction("Click", `(${x}, ${y})`, "ok", { clickX: x, clickY: y, source: "agent" });
const buf = await captureBuf();
const screenshot = buf ? buf.toString('base64') : null;
res.json({ success: true, screenshot, url: page.url() });
} catch(e) { res.json({ success: false, error: e.message }); }
});
app.post('/live/type', async (req, res) => {
if (!page) return res.json({ success: false, error: 'No active page' });
try {
await page.keyboard.type(req.body.text || '');
res.json({ success: true });
} catch(e) { res.json({ success: false, error: e.message }); }
});
app.post('/live/scroll', async (req, res) => {
if (!page) return res.json({ success: false, error: 'No active page' });
try {
const { direction = 'down', amount = 300 } = req.body;
const delta = direction === 'up' ? -amount : amount;
await page.mouse.wheel(0, delta);
await new Promise(r => setTimeout(r, 300));
const buf = await captureBuf();
const screenshot = buf ? buf.toString('base64') : null;
res.json({ success: true, screenshot });
} catch(e) { res.json({ success: false, error: e.message }); }
});
app.post('/live/key', async (req, res) => {
if (!page) return res.json({ success: false, error: 'No active page' });
try {
await page.keyboard.press(req.body.key || 'Enter');
res.json({ success: true });
} catch(e) { res.json({ success: false, error: e.message }); }
});
// ── Fast raw JPEG frame — serves latestFrame directly, no base64/JSON overhead
app.get('/frame.jpg', (req, res) => {
if (!latestFrame) {
res.status(204).end();
return;
}
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'no-cache, no-store');
res.end(latestFrame);
});
app.post('/record/start', (req, res) => {
if (recording) return res.json({ success: false, error: 'Already recording' });
res.json({ success: true, id: startSession() });
});
app.post('/record/stop', (req, res) => {
if (!recording) return res.json({ success: false, error: 'Not recording' });
res.json({ success: true, id: stopSession(true) }); // manual stop — always keep
});
app.get('/record/status', (req, res) => {
res.json({ recording, sessionId: currentSession ? currentSession.id : null, frameCount: currentSession ? currentSession.frameIndex : 0 });
});
app.get('/sessions', (req, res) => {
try {
const sessions = fs.readdirSync(SESSIONS_DIR)
.filter(d => fs.existsSync(path.join(SESSIONS_DIR, d, 'meta.json')))
.map(d => {
const meta = JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, d, 'meta.json'), 'utf8'));
const namePath = path.join(SESSIONS_DIR, d, 'name.txt');
const name = fs.existsSync(namePath) ? fs.readFileSync(namePath, 'utf8').trim() : null;
const w = meta.width || 1280, h = meta.height || 800;
const ratio = w / h;
const format = ratio < 0.75 ? 'vertical' : ratio > 1.4 ? 'wide' : 'square';
return {
...meta,
name,
width: w, height: h, format,
mp4Ready: fs.existsSync(path.join(SESSIONS_DIR, d, 'replay.mp4')),
};
})
.sort((a, b) => b.startTime.localeCompare(a.startTime));
res.json({ sessions });
} catch (e) {
res.json({ sessions: [] });
}
});
app.patch('/sessions/:id/rename', (req, res) => {
const id = req.params.id;
if (id.includes('..') || id.includes('/')) return res.status(400).json({ error: 'invalid' });
const sessionDir = path.join(SESSIONS_DIR, id);
if (!fs.existsSync(sessionDir)) return res.status(404).json({ error: 'Not found' });
const name = (req.body.name || '').trim().substring(0, 60);
const namePath = path.join(sessionDir, 'name.txt');
if (name) {
fs.writeFileSync(namePath, name);
} else {
try { fs.unlinkSync(namePath); } catch(e) {}
}
res.json({ ok: true, name: name || null });
});
app.get('/sessions/:id/mp4', (req, res) => {
const id = req.params.id;
if (id.includes('..') || id.includes('/')) return res.status(400).json({ error: 'invalid' });
const f = path.join(SESSIONS_DIR, id, 'replay.mp4');
if (!fs.existsSync(f)) return res.status(404).json({ error: 'MP4 not ready yet' });
res.sendFile(f);
});
app.get('/sessions/:id/thumbnail', (req, res) => {
const id = req.params.id;
if (id.includes('..') || id.includes('/')) return res.status(400).json({ error: 'invalid' });
const f = path.join(SESSIONS_DIR, id, 'thumbnail.jpg');
if (!fs.existsSync(f)) return res.status(404).send('');
res.sendFile(f);
});
app.delete('/sessions/:id', (req, res) => {
const id = req.params.id;
if (id.includes('..') || id.includes('/')) return res.status(400).json({ error: 'invalid' });
const sessionDir = path.join(SESSIONS_DIR, id);
if (!fs.existsSync(sessionDir)) return res.status(404).json({ error: 'Not found' });
try {
fs.rmSync(sessionDir, { recursive: true, force: true });
res.json({ ok: true });
} catch(e) {
res.status(500).json({ error: e.message });
}
});
app.get('/sessions/:id/edited/:filename', (req, res) => {
const id = req.params.id;
const filename = req.params.filename;
if (id.includes('..') || id.includes('/')) return res.status(400).json({ error: 'invalid' });
if (filename.includes('..') || filename.includes('/')) return res.status(400).json({ error: 'invalid' });
const f = path.join(SESSIONS_DIR, id, filename);
if (!fs.existsSync(f)) return res.status(404).json({ error: 'Not found' });
res.sendFile(f);
});
function buildEditArgs(inputFile, outputFile, opts) {
const { trimStart = 0, trimEnd = null, speed = 1, cropVertical = false, text = '', textPos = 'top' } = opts;
// Reject shell metacharacters in text overlay to prevent injection via ffmpeg filter strings
if (text && /[`$;|&<>(){}[\]!#]/.test(text)) {
throw new Error('Text contains disallowed characters');
}
let filters = [];
if (speed !== 1 && speed > 0) filters.push(`setpts=${(1/speed).toFixed(4)}*PTS`);
if (cropVertical) {
filters.push(`crop=ih*9/16:ih:(iw-ih*9/16)/2:0`);
filters.push(`scale=1080:1920`);
}
if (text) {
const yMap = { top: 'h*0.08', middle: '(h-text_h)/2', bottom: 'h*0.85' };
const y = yMap[textPos] || 'h*0.08';
const escaped = text.replace(/\\/g,'\\\\').replace(/'/g,"\u2019").replace(/:/g,'\\:');
filters.push(`drawtext=text='${escaped}':fontsize=52:fontcolor=white:x=(w-text_w)/2:y=${y}:box=1:boxcolor=black@0.55:boxborderw=12`);
}
const args = ['-y'];
if (trimStart > 0) args.push('-ss', String(trimStart));
args.push('-i', inputFile);
if (trimEnd && trimEnd > trimStart) args.push('-t', String(trimEnd - trimStart));
if (filters.length) args.push('-vf', filters.join(','));
args.push('-c:v', 'libx264', '-crf', '23', '-pix_fmt', 'yuv420p', outputFile);
return args;
}
app.post('/sessions/:id/edit', (req, res) => {
const id = req.params.id;
if (id.includes('..') || id.includes('/')) return res.status(400).json({ error: 'invalid' });
const inputFile = path.join(SESSIONS_DIR, id, 'replay.mp4');
if (!fs.existsSync(inputFile)) return res.status(404).json({ error: 'Source MP4 not found' });
const outName = `edited-${Date.now()}.mp4`;
const outputFile = path.join(SESSIONS_DIR, id, outName);
let editArgs;
try { editArgs = buildEditArgs(inputFile, outputFile, req.body); }
catch(e) { return res.status(400).json({ error: e.message }); }
pushAction('Editing', `${id} — ffmpeg ${editArgs.slice(0, 4).join(' ')}...`, 'info');
execFile('ffmpeg', editArgs, { timeout: 300000 }, (err) => {
if (err) {
pushAction('Edit failed', err.message.substring(0, 80), 'err');
return res.status(500).json({ error: err.message });
}
const size = (() => { try { return (fs.statSync(outputFile).size/1024/1024).toFixed(1)+'MB'; } catch(e) { return '?'; } })();
pushAction('Edit ready', `${size} — /sessions/${req.params.id}/edited/${outName}`, 'ok');
res.json({ success: true, file: outName, url: `/sessions/${req.params.id}/edited/${outName}`, size });
});
});
// Auto-rotate session every 600 frames (~5min at 2fps) so replays appear reasonably
const AUTO_ROTATE_FRAMES = 600;
function saveFrame(buf, label) {
if (!recording || !currentSession) return;
const n = String(currentSession.frameIndex).padStart(4, '0');
fs.writeFileSync(path.join(currentSession.dir, `frame-${n}.jpg`), buf);
currentSession.actions.push({ frame: currentSession.frameIndex, label, time: new Date().toISOString() });
// Mark session as having real activity (not just idle auto-frames)
if (label !== 'auto') currentSession.hasActivity = true;
currentSession.frameIndex++;
// Auto-rotate if too many frames — auto-rotate purges blank sessions
if (currentSession.frameIndex >= AUTO_ROTATE_FRAMES) {
stopSession(false); // auto, not manual
startSession();
}
}
// ── Screenshot stream — Playwright screenshot polling ─────────────────────────
let ffmpegProc = null;
function startScreenshotStream() {
if (ffmpegProc) return; // reuse var as interval handle
console.log('[stream] Starting Playwright screenshot stream at ~2fps');
ffmpegProc = setInterval(async () => {
try {
const buf = await captureStreamBuf();
if (buf) {
latestFrame = buf;
if (streamClients.length > 0) pushStreamFrame(buf);
}
} catch(e) {}
}, 80);
}
// ── Auto-frame timer — saves a frame every 500ms while recording ──────────────
let autoFrameTimer = null;
function startAutoFrameTimer() {
if (autoFrameTimer) return;
autoFrameTimer = setInterval(() => {
if (!recording || !page) return;
// Use cached frame — instant, no extra Playwright call
if (latestFrame) saveFrame(latestFrame, 'auto');
}, 500);
}
// ── Browser ───────────────────────────────────────────────────────────────────
let browserRestarting = false;
async function initBrowser() {
// Use Playwright bundled Chromium in headless mode
browser = await chromium.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-backgrounding-occluded-windows',
'--window-position=0,0',
'--window-size=1280,720',
'--app=about:blank',
'--lang=en-US,en',
]
});
// Track Chromium's real major version so the UA can't drift from the JS fingerprint.
const browserMajor = browser.version().split('.')[0];
const contextOptions = {
viewport: { width: 1280, height: 720 },
userAgent: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${browserMajor}.0.0.0 Safari/537.36`,
locale: 'en-US',
timezoneId: 'America/New_York',
extraHTTPHeaders: { 'Accept-Language': 'en-US,en;q=0.9' },
};
context = await browser.newContext(contextOptions);
// Auto-restart browser if Chromium crashes
browser.on('disconnected', () => {
if (browserRestarting) return;
browserRestarting = true;
console.error('[browser] Chromium disconnected — restarting in 3s...');
// Clear state immediately and notify clients so UI doesn't show stale tabs
tabs = []; page = null; activeTabId = null; tabCounter = 0;
pushAction('Browser', 'Crashed — restarting...', 'err', { tabs: [] });
setTimeout(async () => {
try {
await initBrowser();
browserRestarting = false;
pushAction('Browser', 'Restarted after crash', 'ok');
} catch(e) {
browserRestarting = false;
console.error('[browser] Restart failed:', e.message);
// Try again in 10s
setTimeout(() => { browserRestarting = false; initBrowser().catch(console.error); }, 10000);
}
}, 3000);
});
await createTab('about:blank');
pushAction('Browser', 'Ready', 'ok');
// Auto-start recording immediately
startSession();
startScreenshotStream();
startAutoFrameTimer(); // saves cached latestFrame to disk for recording
}
// ── Tab endpoints ──────────────────────────────────────────────────────────────
app.get('/tabs', (req, res) => {
res.json({ tabs: getTabList(), activeTabId });
});
app.post('/tabs/new', async (req, res) => {
const { url = 'about:blank' } = req.body;
try {
const id = await createTab(url);
const img = await shot(`New Tab: ${url}`);
res.json({ success: true, id, tabs: getTabList(), screenshot: img, url: page.url() });
} catch(e) {
res.json({ success: false, error: e.message });
}
});
app.post('/tabs/switch', async (req, res) => {
const { id } = req.body;
if (!switchActiveTab(id)) return res.json({ success: false, error: 'Tab not found' });
const img = await shot(`Switch to Tab ${id}`);
res.json({ success: true, id, tabs: getTabList(), screenshot: img, url: page.url() });
});
app.delete('/tabs/:id', async (req, res) => {
const id = parseInt(req.params.id);
const ok = await closeTab(id);
if (!ok) return res.json({ success: false, error: 'Tab not found or last tab' });
const img = await shot('Close Tab');
res.json({ success: true, tabs: getTabList(), screenshot: img, url: page ? page.url() : '' });
});
// Returns raw Buffer (not base64)
async function captureBuf() {
if (!page) return null;
try { return await page.screenshot({ type: 'jpeg', quality: 80 }); }
catch (e) { return null; }
}
// Low-quality capture for the MJPEG stream — saves bandwidth
async function captureStreamBuf() {
if (!page) return null;
try { return await page.screenshot({ type: 'jpeg', quality: 75 }); }
catch (e) { return null; }
}
// Take screenshot, save frame if recording, return base64
async function shot(label) {
const buf = await captureBuf();
if (!buf) return null;
if (recording) saveFrame(buf, label);
return buf.toString('base64');
}
app.get('/screenshot', async (req, res) => {
// Return cached frame instantly if available — no new Playwright capture needed
const buf = latestFrame || await captureBuf();
if (!buf) return res.status(500).json({ error: 'No browser' });
res.json({ screenshot: buf.toString('base64'), url: page ? page.url() : '' });
});
// Store highlights for AI to read
let highlights = [];
// Bot asks user to highlight something
let waitforHighlight = null; // { message, since }
app.post('/waitfor-highlight', (req, res) => {