agent-device-lab 0.4.0 → 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.4.0`). 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
 
@@ -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();
@@ -38,7 +38,8 @@ export class Dashboard {
38
38
  token = newToken();
39
39
  /** Read-only access; this is the URL placed in start and status results that agents see. */
40
40
  viewToken = newToken();
41
- events = new Set();
41
+ /** Each authenticated stream keeps its access level for the next session's handoff. */
42
+ events = new Map();
42
43
  viewers = new Set();
43
44
  lastFrame;
44
45
  cast;
@@ -63,7 +64,7 @@ export class Dashboard {
63
64
  d.server.listen(opts.port ?? 0, '127.0.0.1', () => resolve());
64
65
  });
65
66
  d.port = d.server.address().port;
66
- d.heartbeat = setInterval(() => { for (const res of d.events)
67
+ d.heartbeat = setInterval(() => { for (const res of d.events.keys())
67
68
  res.write(': ping\n\n'); }, HEARTBEAT_MS);
68
69
  d.heartbeat.unref();
69
70
  return d;
@@ -98,16 +99,20 @@ export class Dashboard {
98
99
  // ---------- feed ----------
99
100
  onMessage(m) {
100
101
  if (m.event.type === 'reset') {
101
- // 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.
102
104
  this.token = newToken();
103
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
+ }
104
109
  this.lastFrame = undefined;
105
110
  this.endStreams();
106
111
  void this.stopCast();
107
112
  return;
108
113
  }
109
114
  const frame = `id: ${m.seq}\nevent: feed\ndata: ${JSON.stringify(m)}\n\n`;
110
- for (const res of this.events)
115
+ for (const res of this.events.keys())
111
116
  res.write(frame);
112
117
  if (m.event.type === 'status') {
113
118
  const state = m.event.status.state;
@@ -120,7 +125,7 @@ export class Dashboard {
120
125
  }
121
126
  }
122
127
  endStreams() {
123
- for (const res of [...this.events, ...this.viewers])
128
+ for (const res of [...this.events.keys(), ...this.viewers])
124
129
  res.end();
125
130
  this.events.clear();
126
131
  this.viewers.clear();
@@ -221,15 +226,17 @@ export class Dashboard {
221
226
  return void this.sendFile(res, this.opts.feed.sweepFrameFile(sweep[1], sweep[2]));
222
227
  res.writeHead(404).end();
223
228
  }
224
- authorized(req, url) {
229
+ requestToken(req, url) {
225
230
  const header = req.headers.authorization;
226
- 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);
227
235
  return !!given && (same(given, this.token) || same(given, this.viewToken));
228
236
  }
229
237
  /** Whether this request carries the control token (the page shows control buttons only then). */
230
238
  canControl(req, url) {
231
- const header = req.headers.authorization;
232
- const given = header?.startsWith('Bearer ') ? header.slice(7) : url.searchParams.get('token');
239
+ const given = this.requestToken(req, url);
233
240
  return !!given && !!this.opts.control && same(given, this.token);
234
241
  }
235
242
  /**
@@ -321,7 +328,7 @@ export class Dashboard {
321
328
  const snap = this.opts.feed.snapshot();
322
329
  res.write(`id: ${snap.seq}\nevent: snapshot\ndata: ${JSON.stringify(snap)}\n\n`);
323
330
  }
324
- this.events.add(res);
331
+ this.events.set(res, same(this.requestToken(req, url), this.token));
325
332
  res.on('close', () => { this.events.delete(res); });
326
333
  }
327
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.4.0",
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",