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 +1 -1
- package/assets/dashboard/app.js +13 -2
- package/dist/dashboard/server.js +17 -10
- 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.4.
|
|
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.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();
|
package/dist/dashboard/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
229
|
+
requestToken(req, url) {
|
|
225
230
|
const header = req.headers.authorization;
|
|
226
|
-
|
|
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
|
|
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.
|
|
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. */
|
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