agent-device-lab 0.3.1 → 0.4.1
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/README.md +1 -1
- package/assets/dashboard/app.css +20 -1
- package/assets/dashboard/app.js +81 -2
- package/assets/dashboard/index.html +13 -2
- package/dist/core/format.js +45 -4
- package/dist/dashboard/server.js +20 -11
- package/dist/mcp/server.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ The same commands work from a terminal (`agentlab`), from an MCP server for Clau
|
|
|
11
11
|
|
|
12
12
|

|
|
13
13
|
|
|
14
|
-
**Status:** Web V1 (`0.
|
|
14
|
+
**Status:** Web V1 (`0.4.1`). It is tested on Linux x64 (Debian 12 and Ubuntu 24.04) with Node.js 22 and 24. macOS, Windows and arm64 are untested, and Alpine does not work. See [Limitations](#limitations).
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
package/assets/dashboard/app.css
CHANGED
|
@@ -57,8 +57,27 @@ body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.45 syst
|
|
|
57
57
|
.findings-panel { order: 3; }
|
|
58
58
|
.vp-now { margin: 0 0 10px; text-align: center; font-size: 13px; }
|
|
59
59
|
.vp-now strong { font-weight: 600; }
|
|
60
|
+
.vp-toolbar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
|
|
61
|
+
.vp-toolbar h2 { margin: 0; }
|
|
62
|
+
.vp-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
63
|
+
.vp-actions button { font: inherit; font-size: 12px; min-height: 32px; padding: 4px 10px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); background: var(--panel); cursor: pointer; }
|
|
64
|
+
.vp-actions button:hover { border-color: var(--accent); }
|
|
65
|
+
.vp-actions button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
66
|
+
.vp-dialog { width: calc(100vw - 32px); height: calc(100dvh - 32px); max-width: none; max-height: none; padding: 0; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); color: var(--ink); }
|
|
67
|
+
.vp-dialog::backdrop { background: rgb(0 0 0 / .65); }
|
|
68
|
+
.vp-dialog[open] { display: flex; flex-direction: column; }
|
|
69
|
+
.vp-dialog .supervision { margin: 0; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; flex: none; max-height: 30%; overflow: auto; }
|
|
70
|
+
:root:fullscreen .vp-dialog { width: 100vw; height: 100dvh; border: 0; border-radius: 0; }
|
|
71
|
+
.vp-dialog .viewport-panel { position: static; display: flex; flex-direction: column; flex: 1; min-height: 0; border: 0; overflow: auto; }
|
|
72
|
+
.vp-dialog .vp-stage { flex: 1; min-height: 0; display: grid; place-items: center; }
|
|
73
|
+
.vp-dialog .device { width: min(calc(100% - 16px), calc(var(--vp-w, 390) * 1px), calc((var(--stage-height, 600px) - 16px) * var(--vp-w, 390) / var(--vp-h, 844))); transition: none; }
|
|
74
|
+
.vp-dialog .sweep { display: none; }
|
|
75
|
+
.vp-dialog .human-panel { flex: none; max-height: 35%; overflow: auto; }
|
|
76
|
+
.vp-dialog .vp-now, .vp-dialog .vp-toolbar { flex: none; }
|
|
77
|
+
@media (max-width: 720px) { .vp-dialog { width: calc(100vw - 12px); height: calc(100dvh - 12px); } }
|
|
78
|
+
@media (max-height: 600px) { .vp-dialog #vp-display-note { display: none; } .vp-dialog .vp-toolbar { margin-bottom: 6px; } .vp-dialog .vp-now { margin-bottom: 6px; font-size: 12px; } }
|
|
60
79
|
/* Sized to the device on screen (--vp-w × --vp-h in CSS pixels), shrunk to fit the column and the window. */
|
|
61
|
-
.device { position: relative; box-sizing:
|
|
80
|
+
.device { position: relative; box-sizing: content-box; margin: 0 auto; width: min(calc(100% - 16px), calc(var(--vp-w, 390) * 1px), calc(max(404px, 100vh - 346px) * var(--vp-w, 390) / var(--vp-h, 844))); border: 8px solid #20252c; border-radius: 22px; overflow: hidden; background: #000; aspect-ratio: var(--vp-w, 390) / var(--vp-h, 844); transition: width .25s ease; }
|
|
62
81
|
@media (prefers-reduced-motion: reduce) { .device { transition: none; } }
|
|
63
82
|
.device img { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: contain; background: #fff; }
|
|
64
83
|
.device img:not([src]) { visibility: hidden; }
|
package/assets/dashboard/app.js
CHANGED
|
@@ -53,6 +53,16 @@ function connect() {
|
|
|
53
53
|
loadCapabilities();
|
|
54
54
|
source = new EventSource(api('/api/events'));
|
|
55
55
|
source.addEventListener('open', () => setConn('live', 'live'));
|
|
56
|
+
source.addEventListener('session-replaced', (e) => {
|
|
57
|
+
const next = JSON.parse(e.data).token;
|
|
58
|
+
if (typeof next !== 'string' || !/^[\w-]{32}$/.test(next)) return;
|
|
59
|
+
source.close();
|
|
60
|
+
setConn('reconnecting', 'new session…');
|
|
61
|
+
// Replace the fragment without keeping the expired link in history, then reload to discard
|
|
62
|
+
// the previous session's state, pending requests and cached evidence frames.
|
|
63
|
+
history.replaceState(null, '', `#token=${encodeURIComponent(next)}`);
|
|
64
|
+
location.reload();
|
|
65
|
+
});
|
|
56
66
|
source.addEventListener('snapshot', (e) => {
|
|
57
67
|
const snap = JSON.parse(e.data);
|
|
58
68
|
model.timeline = snap.timeline;
|
|
@@ -72,9 +82,9 @@ function connect() {
|
|
|
72
82
|
source.addEventListener('feed', (e) => apply(JSON.parse(e.data).event));
|
|
73
83
|
source.addEventListener('error', () => {
|
|
74
84
|
const s = model.status && model.status.state;
|
|
85
|
+
if (s === 'ended' || s === 'failed') return setConn('closed', 'session over');
|
|
75
86
|
if (source.readyState === EventSource.CLOSED) {
|
|
76
87
|
// The server refused the stream (e.g. 401 after a new session rotated the token) or went away.
|
|
77
|
-
if (s === 'ended' || s === 'failed') return setConn('closed', 'session over');
|
|
78
88
|
setConn('denied', 'disconnected');
|
|
79
89
|
checkAccess();
|
|
80
90
|
} else {
|
|
@@ -270,7 +280,8 @@ function setStatus(s) {
|
|
|
270
280
|
}
|
|
271
281
|
if (s.state === 'ended' || s.state === 'failed') {
|
|
272
282
|
setConn('closed', 'session over');
|
|
273
|
-
|
|
283
|
+
// Keep the event stream attached: an MCP server can start another session and hand us its link.
|
|
284
|
+
// Frame capture still stops when the session ends.
|
|
274
285
|
}
|
|
275
286
|
if (s.control) { model.control = s.control; model.controlEpoch++; }
|
|
276
287
|
renderControl();
|
|
@@ -327,6 +338,74 @@ setInterval(() => {
|
|
|
327
338
|
}, 200);
|
|
328
339
|
document.addEventListener('visibilitychange', updateViewport);
|
|
329
340
|
|
|
341
|
+
// Move the existing viewport into a modal stage: expanding never opens a second stream.
|
|
342
|
+
const viewportPanel = $('viewport-panel');
|
|
343
|
+
const viewportHome = document.createComment('viewport position');
|
|
344
|
+
viewportPanel.before(viewportHome);
|
|
345
|
+
const viewportDialog = $('vp-dialog');
|
|
346
|
+
const supervisionHome = document.createComment('supervision position');
|
|
347
|
+
$('supervision').before(supervisionHome);
|
|
348
|
+
let viewportOpener;
|
|
349
|
+
new ResizeObserver(([entry]) => {
|
|
350
|
+
$('device').style.setProperty('--stage-height', `${entry.contentRect.height}px`);
|
|
351
|
+
}).observe($('vp-stage'));
|
|
352
|
+
|
|
353
|
+
function expandViewport(opener) {
|
|
354
|
+
if (viewportDialog.open) return;
|
|
355
|
+
viewportOpener = opener;
|
|
356
|
+
viewportDialog.append($('supervision'), viewportPanel);
|
|
357
|
+
$('vp-expand').hidden = true;
|
|
358
|
+
$('vp-close').hidden = false;
|
|
359
|
+
$('vp-display-note').textContent = 'Press Escape or close this view to return to the timeline and findings.';
|
|
360
|
+
$('vp-display-note').hidden = false;
|
|
361
|
+
viewportDialog.showModal();
|
|
362
|
+
$('vp-close').focus();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function closeViewport() {
|
|
366
|
+
if (document.fullscreenElement === document.documentElement) await document.exitFullscreen().catch(() => {});
|
|
367
|
+
viewportDialog.close();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
$('vp-expand').addEventListener('click', (e) => expandViewport(e.currentTarget));
|
|
371
|
+
$('vp-close').addEventListener('click', closeViewport);
|
|
372
|
+
$('vp-fullscreen').addEventListener('click', async (e) => {
|
|
373
|
+
if (document.fullscreenElement === document.documentElement) {
|
|
374
|
+
await document.exitFullscreen().catch(() => {});
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
expandViewport(e.currentTarget);
|
|
378
|
+
try {
|
|
379
|
+
await document.documentElement.requestFullscreen();
|
|
380
|
+
} catch {
|
|
381
|
+
$('vp-display-note').textContent = 'Full screen is unavailable in this browser. You can still use the expanded view.';
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
document.addEventListener('fullscreenchange', () => {
|
|
385
|
+
$('vp-fullscreen').textContent = document.fullscreenElement === document.documentElement ? 'Exit full screen' : 'Full screen';
|
|
386
|
+
});
|
|
387
|
+
viewportDialog.addEventListener('cancel', (e) => {
|
|
388
|
+
e.preventDefault();
|
|
389
|
+
if (document.fullscreenElement === document.documentElement) void document.exitFullscreen().catch(() => {});
|
|
390
|
+
else void closeViewport();
|
|
391
|
+
});
|
|
392
|
+
viewportDialog.addEventListener('keydown', (e) => {
|
|
393
|
+
if (e.key !== 'Tab') return;
|
|
394
|
+
const controls = [...viewportDialog.querySelectorAll('button, input, [tabindex="0"]')]
|
|
395
|
+
.filter((el) => !el.disabled && el.getClientRects().length);
|
|
396
|
+
const first = controls[0], last = controls[controls.length - 1];
|
|
397
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
|
|
398
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
|
|
399
|
+
});
|
|
400
|
+
viewportDialog.addEventListener('close', () => {
|
|
401
|
+
supervisionHome.after($('supervision'));
|
|
402
|
+
viewportHome.after(viewportPanel);
|
|
403
|
+
$('vp-expand').hidden = false;
|
|
404
|
+
$('vp-close').hidden = true;
|
|
405
|
+
$('vp-display-note').hidden = true;
|
|
406
|
+
viewportOpener?.focus();
|
|
407
|
+
});
|
|
408
|
+
|
|
330
409
|
// ---------- timeline ----------
|
|
331
410
|
|
|
332
411
|
function renderTimeline() {
|
|
@@ -56,13 +56,23 @@
|
|
|
56
56
|
</section>
|
|
57
57
|
|
|
58
58
|
<main class="grid">
|
|
59
|
-
<section class="panel viewport-panel" aria-label="Live viewport">
|
|
60
|
-
<
|
|
59
|
+
<section id="viewport-panel" class="panel viewport-panel" aria-label="Live viewport">
|
|
60
|
+
<div class="vp-toolbar">
|
|
61
|
+
<h2 id="vp-title">Viewport <span id="vp-caption" class="muted small"></span></h2>
|
|
62
|
+
<div class="vp-actions" role="group" aria-label="Viewport display">
|
|
63
|
+
<button id="vp-expand" type="button" aria-haspopup="dialog">Expand view</button>
|
|
64
|
+
<button id="vp-fullscreen" type="button">Full screen</button>
|
|
65
|
+
<button id="vp-close" type="button" hidden>Close view</button>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<p id="vp-display-note" class="small muted" role="status" hidden></p>
|
|
61
69
|
<p id="vp-now" class="vp-now" role="status"></p>
|
|
70
|
+
<div id="vp-stage" class="vp-stage">
|
|
62
71
|
<div class="device" id="device">
|
|
63
72
|
<img id="viewport" alt="Live view of the device viewport">
|
|
64
73
|
<div id="vp-empty" class="vp-empty">No frames yet</div>
|
|
65
74
|
</div>
|
|
75
|
+
</div>
|
|
66
76
|
<section id="human-panel" class="human-panel" hidden aria-label="Take over the browser">
|
|
67
77
|
<p class="human-banner"><strong>You have control of the browser.</strong> The agent is waiting. Your actions are recorded as descriptions only (no typed text).<span id="human-headed" hidden> You can also use the browser window directly.</span></p>
|
|
68
78
|
<p class="small muted">Click the viewport to tap; scroll over it to scroll.</p>
|
|
@@ -116,5 +126,6 @@
|
|
|
116
126
|
<article id="finding-detail" class="finding-detail" hidden></article>
|
|
117
127
|
</section>
|
|
118
128
|
</main>
|
|
129
|
+
<dialog id="vp-dialog" class="vp-dialog" aria-labelledby="vp-title"></dialog>
|
|
119
130
|
</body>
|
|
120
131
|
</html>
|
package/dist/core/format.js
CHANGED
|
@@ -14,6 +14,8 @@ export function formatControl(c) {
|
|
|
14
14
|
parts.push(c.checked ? 'checked' : 'unchecked');
|
|
15
15
|
if (c.pressed !== undefined)
|
|
16
16
|
parts.push(c.pressed ? 'pressed' : 'not-pressed');
|
|
17
|
+
if (c.selected !== undefined)
|
|
18
|
+
parts.push(c.selected ? 'selected' : 'not-selected');
|
|
17
19
|
if (c.expanded !== undefined)
|
|
18
20
|
parts.push(c.expanded ? 'expanded' : 'collapsed');
|
|
19
21
|
for (const flag of ['disabled', 'required', 'invalid', 'focused'])
|
|
@@ -98,6 +100,8 @@ export function formatChanges(c) {
|
|
|
98
100
|
}
|
|
99
101
|
if (c.route)
|
|
100
102
|
items.push(`route ${c.route.from} → ${c.route.to}`);
|
|
103
|
+
if (c.title)
|
|
104
|
+
items.push(`title ${q(c.title.from)} → ${q(c.title.to)}`);
|
|
101
105
|
if (c.dialog) {
|
|
102
106
|
if (c.dialog.to !== undefined && c.dialog.from === undefined)
|
|
103
107
|
items.push(`+ dialog ${q(c.dialog.to)}`);
|
|
@@ -136,7 +140,7 @@ export function formatChanges(c) {
|
|
|
136
140
|
items.push(`- layout ${f.kind} resolved`);
|
|
137
141
|
if (items.length > MAX_CHANGE_ITEMS) {
|
|
138
142
|
const rest = items.length - MAX_CHANGE_ITEMS;
|
|
139
|
-
return [...items.slice(0, MAX_CHANGE_ITEMS), `… ${rest} more (
|
|
143
|
+
return [...items.slice(0, MAX_CHANGE_ITEMS), `… ${rest} more changes (observe for current state; CLI --json for all changes)`];
|
|
140
144
|
}
|
|
141
145
|
return items;
|
|
142
146
|
}
|
|
@@ -144,7 +148,7 @@ export function formatAction(r) {
|
|
|
144
148
|
const target = r.target ? ` ${r.target.role} ${q(r.target.name)}` : '';
|
|
145
149
|
const head = `${r.action}${r.ref ? ` ${r.ref}` : ''}${target}`;
|
|
146
150
|
if (r.outcome === 'error') {
|
|
147
|
-
return [`${head} → ERROR ${formatError(r.error)}`, ...r.notes.map((n) => `note: ${n}`)].join('\n');
|
|
151
|
+
return [`${head} → ERROR ${formatError(r.error)}`, ...r.notes.map((n) => `note: ${n}`), formatActionDiagnostics(r)].join('\n');
|
|
148
152
|
}
|
|
149
153
|
const lines = [`${head} → ok (${r.method}, ${r.elapsedMs}ms, ${r.settle ? formatSettle(r.settle) : 'settle unknown'})`];
|
|
150
154
|
for (const n of r.notes)
|
|
@@ -166,15 +170,20 @@ export function formatAction(r) {
|
|
|
166
170
|
}
|
|
167
171
|
if (!c.reset) {
|
|
168
172
|
const o = r.observation;
|
|
173
|
+
if (o.omitted)
|
|
174
|
+
lines.push(`controls: ${o.omitted} omitted by budget (observe with a larger limit for current controls)`);
|
|
169
175
|
if (c.removed.length)
|
|
170
176
|
lines.push(`refs: ${c.removed.length <= 8 ? c.removed.map((x) => x.ref).join(', ') : `${c.removed.length} earlier refs`} no longer exist; act on refs from this result or a fresh observe`);
|
|
171
177
|
if (o.layout.length && !c.layoutAdded.length)
|
|
172
178
|
lines.push(`layout: ${o.layout.length} existing flag(s) still present`);
|
|
173
179
|
}
|
|
180
|
+
lines.push(formatActionDiagnostics(r));
|
|
181
|
+
return lines.join('\n');
|
|
182
|
+
}
|
|
183
|
+
function formatActionDiagnostics(r) {
|
|
174
184
|
const errs = r.newConsoleErrors.map((e) => q(e.text)).join('; ');
|
|
175
185
|
const fails = r.newFailedRequests.map((f) => `${f.method} ${f.url} ${f.status ?? f.failure}`).join('; ');
|
|
176
|
-
|
|
177
|
-
return lines.join('\n');
|
|
186
|
+
return `console: ${r.newConsoleErrors.length} new errors${errs ? ` (${errs})` : ''} network: ${r.newFailedRequests.length} new failures${fails ? ` (${fails})` : ''}`;
|
|
178
187
|
}
|
|
179
188
|
export function formatStart(r) {
|
|
180
189
|
const s = r.session;
|
|
@@ -255,8 +264,37 @@ export function formatTabs(tabs) {
|
|
|
255
264
|
}
|
|
256
265
|
export function formatError(e) {
|
|
257
266
|
const lines = [`${e.code}: ${e.message}`];
|
|
267
|
+
lines.push(`recoverable: ${e.recoverable}`);
|
|
258
268
|
if (e.hint)
|
|
259
269
|
lines.push(`hint: ${e.hint}`);
|
|
270
|
+
const control = e.details?.control;
|
|
271
|
+
if (control && typeof control === 'object') {
|
|
272
|
+
const s = control;
|
|
273
|
+
const parts = [];
|
|
274
|
+
if (typeof s.mode === 'string')
|
|
275
|
+
parts.push(s.mode);
|
|
276
|
+
if (typeof s.pending === 'string')
|
|
277
|
+
parts.push(`pending=${s.pending}`);
|
|
278
|
+
if (typeof s.by === 'string')
|
|
279
|
+
parts.push(`by=${q(s.by)}`);
|
|
280
|
+
if (typeof s.since === 'string')
|
|
281
|
+
parts.push(`since=${q(s.since)}`);
|
|
282
|
+
if (typeof s.observeRequired === 'boolean')
|
|
283
|
+
parts.push(`observeRequired=${s.observeRequired}`);
|
|
284
|
+
if (typeof s.interrupt === 'boolean')
|
|
285
|
+
parts.push(`interrupt=${s.interrupt}`);
|
|
286
|
+
if (typeof s.humanInteractions === 'number')
|
|
287
|
+
parts.push(`humanInteractions=${s.humanInteractions}`);
|
|
288
|
+
if (s.busy && typeof s.busy === 'object') {
|
|
289
|
+
const busy = s.busy;
|
|
290
|
+
if (typeof busy.command === 'string')
|
|
291
|
+
parts.push(`busy=${q(busy.command)}`);
|
|
292
|
+
if (typeof busy.since === 'string')
|
|
293
|
+
parts.push(`busySince=${q(busy.since)}`);
|
|
294
|
+
}
|
|
295
|
+
if (parts.length)
|
|
296
|
+
lines.push(`control: ${parts.join(' ')}`);
|
|
297
|
+
}
|
|
260
298
|
const tail = e.details?.logTail;
|
|
261
299
|
if (Array.isArray(tail) && tail.length)
|
|
262
300
|
lines.push('server log tail:', ...tail.map((l) => ` ${l}`));
|
|
@@ -274,6 +312,9 @@ export function formatError(e) {
|
|
|
274
312
|
const candidates = e.details?.candidates;
|
|
275
313
|
if (Array.isArray(candidates))
|
|
276
314
|
lines.push(`candidates: ${candidates.map((c) => `${c.ref} ${c.role} ${q(c.name)}`).join('; ')}`);
|
|
315
|
+
const sameRole = e.details?.sameRole;
|
|
316
|
+
if (Array.isArray(sameRole))
|
|
317
|
+
lines.push(`same role: ${sameRole.map((c) => q(String(c))).join('; ')}`);
|
|
277
318
|
return lines.join('\n');
|
|
278
319
|
}
|
|
279
320
|
/** A person's interaction in words, e.g. `tapped button "Save"`, `typed into textbox "Password" (hidden)`. */
|
package/dist/dashboard/server.js
CHANGED
|
@@ -10,7 +10,9 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
import { readFile } from 'node:fs/promises';
|
|
11
11
|
import { createServer } from 'node:http';
|
|
12
12
|
import { LabError } from '../core/schema.js';
|
|
13
|
-
|
|
13
|
+
// Preserve desktop text at 1× CSS resolution; Lab never upscales smaller devices.
|
|
14
|
+
// Keep the rate conservative: clearer frames need more encoding and bandwidth.
|
|
15
|
+
export const DEFAULT_SCREENCAST = { maxFps: 5, maxWidth: 1920, quality: 85 };
|
|
14
16
|
const BOUNDARY = 'agentlab-frame';
|
|
15
17
|
const PART = `\r\n--${BOUNDARY}\r\nContent-Type: image/jpeg\r\n\r\n`;
|
|
16
18
|
/** A slow viewer skips frames rather than buffering them without limit. */
|
|
@@ -36,7 +38,8 @@ export class Dashboard {
|
|
|
36
38
|
token = newToken();
|
|
37
39
|
/** Read-only access; this is the URL placed in start and status results that agents see. */
|
|
38
40
|
viewToken = newToken();
|
|
39
|
-
|
|
41
|
+
/** Each authenticated stream keeps its access level for the next session's handoff. */
|
|
42
|
+
events = new Map();
|
|
40
43
|
viewers = new Set();
|
|
41
44
|
lastFrame;
|
|
42
45
|
cast;
|
|
@@ -61,7 +64,7 @@ export class Dashboard {
|
|
|
61
64
|
d.server.listen(opts.port ?? 0, '127.0.0.1', () => resolve());
|
|
62
65
|
});
|
|
63
66
|
d.port = d.server.address().port;
|
|
64
|
-
d.heartbeat = setInterval(() => { for (const res of d.events)
|
|
67
|
+
d.heartbeat = setInterval(() => { for (const res of d.events.keys())
|
|
65
68
|
res.write(': ping\n\n'); }, HEARTBEAT_MS);
|
|
66
69
|
d.heartbeat.unref();
|
|
67
70
|
return d;
|
|
@@ -96,16 +99,20 @@ export class Dashboard {
|
|
|
96
99
|
// ---------- feed ----------
|
|
97
100
|
onMessage(m) {
|
|
98
101
|
if (m.event.type === 'reset') {
|
|
99
|
-
//
|
|
102
|
+
// Rotate credentials, but let already authenticated, connected dashboards follow the new
|
|
103
|
+
// session. Send only the matching access level, outside the shared/replayable feed.
|
|
100
104
|
this.token = newToken();
|
|
101
105
|
this.viewToken = newToken();
|
|
106
|
+
for (const [res, control] of this.events) {
|
|
107
|
+
res.write(`event: session-replaced\ndata: ${JSON.stringify({ token: control ? this.token : this.viewToken })}\n\n`);
|
|
108
|
+
}
|
|
102
109
|
this.lastFrame = undefined;
|
|
103
110
|
this.endStreams();
|
|
104
111
|
void this.stopCast();
|
|
105
112
|
return;
|
|
106
113
|
}
|
|
107
114
|
const frame = `id: ${m.seq}\nevent: feed\ndata: ${JSON.stringify(m)}\n\n`;
|
|
108
|
-
for (const res of this.events)
|
|
115
|
+
for (const res of this.events.keys())
|
|
109
116
|
res.write(frame);
|
|
110
117
|
if (m.event.type === 'status') {
|
|
111
118
|
const state = m.event.status.state;
|
|
@@ -118,7 +125,7 @@ export class Dashboard {
|
|
|
118
125
|
}
|
|
119
126
|
}
|
|
120
127
|
endStreams() {
|
|
121
|
-
for (const res of [...this.events, ...this.viewers])
|
|
128
|
+
for (const res of [...this.events.keys(), ...this.viewers])
|
|
122
129
|
res.end();
|
|
123
130
|
this.events.clear();
|
|
124
131
|
this.viewers.clear();
|
|
@@ -219,15 +226,17 @@ export class Dashboard {
|
|
|
219
226
|
return void this.sendFile(res, this.opts.feed.sweepFrameFile(sweep[1], sweep[2]));
|
|
220
227
|
res.writeHead(404).end();
|
|
221
228
|
}
|
|
222
|
-
|
|
229
|
+
requestToken(req, url) {
|
|
223
230
|
const header = req.headers.authorization;
|
|
224
|
-
|
|
231
|
+
return header?.startsWith('Bearer ') ? header.slice(7) : url.searchParams.get('token');
|
|
232
|
+
}
|
|
233
|
+
authorized(req, url) {
|
|
234
|
+
const given = this.requestToken(req, url);
|
|
225
235
|
return !!given && (same(given, this.token) || same(given, this.viewToken));
|
|
226
236
|
}
|
|
227
237
|
/** Whether this request carries the control token (the page shows control buttons only then). */
|
|
228
238
|
canControl(req, url) {
|
|
229
|
-
const
|
|
230
|
-
const given = header?.startsWith('Bearer ') ? header.slice(7) : url.searchParams.get('token');
|
|
239
|
+
const given = this.requestToken(req, url);
|
|
231
240
|
return !!given && !!this.opts.control && same(given, this.token);
|
|
232
241
|
}
|
|
233
242
|
/**
|
|
@@ -319,7 +328,7 @@ export class Dashboard {
|
|
|
319
328
|
const snap = this.opts.feed.snapshot();
|
|
320
329
|
res.write(`id: ${snap.seq}\nevent: snapshot\ndata: ${JSON.stringify(snap)}\n\n`);
|
|
321
330
|
}
|
|
322
|
-
this.events.
|
|
331
|
+
this.events.set(res, same(this.requestToken(req, url), this.token));
|
|
323
332
|
res.on('close', () => { this.events.delete(res); });
|
|
324
333
|
}
|
|
325
334
|
/** MJPEG stream of the live viewport. Capture runs only while at least one of these is open. */
|
package/dist/mcp/server.js
CHANGED
|
@@ -22,8 +22,8 @@ export async function runMcpServer(opts) {
|
|
|
22
22
|
const version = productVersion();
|
|
23
23
|
const ui = opts.ui ?? true;
|
|
24
24
|
const feed = new SessionFeed();
|
|
25
|
-
// A new session rotates the dashboard's tokens
|
|
26
|
-
//
|
|
25
|
+
// A new session rotates the dashboard's tokens. Connected tabs receive a handoff; re-publish the
|
|
26
|
+
// person's control URL too, so `agentlab ui` and newly opened tabs use the current credentials.
|
|
27
27
|
let announce;
|
|
28
28
|
const host = new SessionHost({
|
|
29
29
|
stateDir: opts.stateDir, evidenceFrames: ui,
|
package/package.json
CHANGED