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 CHANGED
@@ -11,7 +11,7 @@ The same commands work from a terminal (`agentlab`), from an MCP server for Clau
11
11
 
12
12
  ![The dashboard after a run on the demo app: the live viewport in the centre, the timeline of actions on the left, and the findings on the right with one selected to show its evidence and reproduction steps](docs/images/dashboard.png)
13
13
 
14
- **Status:** Web V1 (`0.3.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).
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
 
@@ -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: border-box; margin: 0 auto; width: min(100%, calc(var(--vp-w, 390) * 1px + 16px), calc(max(420px, 100vh - 330px) * 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; }
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; }
@@ -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
- if (source) source.close();
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
- <h2>Viewport <span id="vp-caption" class="muted small"></span></h2>
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>
@@ -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 (use --json for all)`];
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
- lines.push(`console: ${r.newConsoleErrors.length} new errors${errs ? ` (${errs})` : ''} network: ${r.newFailedRequests.length} new failures${fails ? ` (${fails})` : ''}`);
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)`. */
@@ -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
- export const DEFAULT_SCREENCAST = { maxFps: 5, maxWidth: 800, quality: 60 };
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
- events = new Set();
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
- // A new session: previous viewers and the old tokens lose access.
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
- authorized(req, url) {
229
+ requestToken(req, url) {
223
230
  const header = req.headers.authorization;
224
- const given = header?.startsWith('Bearer ') ? header.slice(7) : url.searchParams.get('token');
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 header = req.headers.authorization;
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.add(res);
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. */
@@ -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 (old viewers lose access), so the person's control URL
26
- // is re-published whenever one starts; it would otherwise stop working after the agent's second start.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device-lab",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Lets a coding agent start your web app, use it at phone, tablet or desktop size, and report what breaks, while you watch.",
5
5
  "keywords": [
6
6
  "agent",