agent-device-lab 0.3.0 → 0.4.0

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
@@ -1,17 +1,17 @@
1
1
  # Agent Device Lab
2
2
 
3
- Agent Device Lab lets a coding agent start your web app, use it on a phone-sized screen, and report what breaks, while you watch.
3
+ Agent Device Lab lets a coding agent start your web app, use it at phone, tablet or desktop size, and report what breaks, while you watch.
4
4
 
5
5
  - **It runs your project.** The lab starts the services your app needs, in order, or attaches to ones already running. When it finishes it stops only what it started.
6
6
  - **It gives the agent a small, exact view.** The agent gets a short list of the controls on screen, each with a ref (`e1`, `e2`, ...). After every click or keystroke it gets only what changed.
7
- - **It finds layout defects.** It records problems a person would hit on a small screen, such as a button pushed off the edge, each with measurements and the steps to reproduce it.
7
+ - **It finds layout defects.** It records problems a person would hit at each screen size, such as a button pushed off the edge on a phone, each with measurements and the steps to reproduce it.
8
8
  - **You stay in charge.** A local dashboard shows the agent's screen live. You can pause the agent, take over the browser, hand it back, or stop the run.
9
9
 
10
10
  The same commands work from a terminal (`agentlab`), from an MCP server for Claude Code and Codex, and in CI with predictable exit codes.
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.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.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).
15
15
 
16
16
  ## Install
17
17
 
@@ -43,7 +43,7 @@ In your own project the short version is:
43
43
  cd /path/to/your-app
44
44
 
45
45
  agentlab init # shows what it detected and the agentlab.json it proposes; writes nothing until you confirm
46
- agentlab start # starts your app, opens a phone-sized browser, prints the first observation
46
+ agentlab start # starts your app, opens a browser at the size in your profile (phone by default), prints the first observation
47
47
  agentlab ui # opens the dashboard so you can watch
48
48
 
49
49
  agentlab observe # the controls on screen, with refs
@@ -69,6 +69,8 @@ The lab reads one file in your project, `agentlab.json`. A minimal one:
69
69
  }
70
70
  ```
71
71
 
72
+ `device` sets the screen the agent uses: `mobile-320`, `mobile-390`, `tablet-768` or `desktop-1440`. Everything works the same way at each size.
73
+
72
74
  The lab runs only the commands written there, so committing the file is how you approve them. Every setting is in [docs/configuration.md](docs/configuration.md).
73
75
 
74
76
  ## Use it from your coding agent
@@ -138,6 +140,12 @@ Measurements of the lab, including a benchmark against Playwright MCP and runs o
138
140
 
139
141
  Building, testing and the project's rules are in [CONTRIBUTING.md](CONTRIBUTING.md). Report a security issue privately as described in [SECURITY.md](SECURITY.md), not in a public issue. Changes by release are in [CHANGELOG.md](CHANGELOG.md).
140
142
 
143
+ ## Support development
144
+
145
+ If Agent Device Lab is useful to you, you can support its ongoing development
146
+ for $2/month on [Patreon](https://www.patreon.com/Ikafa).
147
+ Support is completely optional.
148
+
141
149
  ## Licence
142
150
 
143
151
  Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE). Copyright 2026 Colony Innovations.
@@ -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; }
@@ -327,6 +327,74 @@ setInterval(() => {
327
327
  }, 200);
328
328
  document.addEventListener('visibilitychange', updateViewport);
329
329
 
330
+ // Move the existing viewport into a modal stage: expanding never opens a second stream.
331
+ const viewportPanel = $('viewport-panel');
332
+ const viewportHome = document.createComment('viewport position');
333
+ viewportPanel.before(viewportHome);
334
+ const viewportDialog = $('vp-dialog');
335
+ const supervisionHome = document.createComment('supervision position');
336
+ $('supervision').before(supervisionHome);
337
+ let viewportOpener;
338
+ new ResizeObserver(([entry]) => {
339
+ $('device').style.setProperty('--stage-height', `${entry.contentRect.height}px`);
340
+ }).observe($('vp-stage'));
341
+
342
+ function expandViewport(opener) {
343
+ if (viewportDialog.open) return;
344
+ viewportOpener = opener;
345
+ viewportDialog.append($('supervision'), viewportPanel);
346
+ $('vp-expand').hidden = true;
347
+ $('vp-close').hidden = false;
348
+ $('vp-display-note').textContent = 'Press Escape or close this view to return to the timeline and findings.';
349
+ $('vp-display-note').hidden = false;
350
+ viewportDialog.showModal();
351
+ $('vp-close').focus();
352
+ }
353
+
354
+ async function closeViewport() {
355
+ if (document.fullscreenElement === document.documentElement) await document.exitFullscreen().catch(() => {});
356
+ viewportDialog.close();
357
+ }
358
+
359
+ $('vp-expand').addEventListener('click', (e) => expandViewport(e.currentTarget));
360
+ $('vp-close').addEventListener('click', closeViewport);
361
+ $('vp-fullscreen').addEventListener('click', async (e) => {
362
+ if (document.fullscreenElement === document.documentElement) {
363
+ await document.exitFullscreen().catch(() => {});
364
+ return;
365
+ }
366
+ expandViewport(e.currentTarget);
367
+ try {
368
+ await document.documentElement.requestFullscreen();
369
+ } catch {
370
+ $('vp-display-note').textContent = 'Full screen is unavailable in this browser. You can still use the expanded view.';
371
+ }
372
+ });
373
+ document.addEventListener('fullscreenchange', () => {
374
+ $('vp-fullscreen').textContent = document.fullscreenElement === document.documentElement ? 'Exit full screen' : 'Full screen';
375
+ });
376
+ viewportDialog.addEventListener('cancel', (e) => {
377
+ e.preventDefault();
378
+ if (document.fullscreenElement === document.documentElement) void document.exitFullscreen().catch(() => {});
379
+ else void closeViewport();
380
+ });
381
+ viewportDialog.addEventListener('keydown', (e) => {
382
+ if (e.key !== 'Tab') return;
383
+ const controls = [...viewportDialog.querySelectorAll('button, input, [tabindex="0"]')]
384
+ .filter((el) => !el.disabled && el.getClientRects().length);
385
+ const first = controls[0], last = controls[controls.length - 1];
386
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last?.focus(); }
387
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first?.focus(); }
388
+ });
389
+ viewportDialog.addEventListener('close', () => {
390
+ supervisionHome.after($('supervision'));
391
+ viewportHome.after(viewportPanel);
392
+ $('vp-expand').hidden = false;
393
+ $('vp-close').hidden = true;
394
+ $('vp-display-note').hidden = true;
395
+ viewportOpener?.focus();
396
+ });
397
+
330
398
  // ---------- timeline ----------
331
399
 
332
400
  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. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-device-lab",
3
- "version": "0.3.0",
4
- "description": "Lets a coding agent start your web app, use it on a phone-sized screen, and report what breaks, while you watch.",
3
+ "version": "0.4.0",
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",
7
7
  "coding-agent",
@@ -9,6 +9,7 @@
9
9
  "playwright",
10
10
  "responsive",
11
11
  "mobile",
12
+ "desktop",
12
13
  "testing",
13
14
  "dev-server",
14
15
  "claude-code",
@@ -24,6 +25,10 @@
24
25
  "bugs": {
25
26
  "url": "https://github.com/Colony-Innovations/agent-device-lab/issues"
26
27
  },
28
+ "funding": {
29
+ "type": "patreon",
30
+ "url": "https://www.patreon.com/Ikafa"
31
+ },
27
32
  "type": "module",
28
33
  "bin": {
29
34
  "agentlab": "bin/agentlab.js"
@@ -49,7 +54,7 @@
49
54
  "verify:package": "node scripts/verify-package.mjs"
50
55
  },
51
56
  "dependencies": {
52
- "@modelcontextprotocol/sdk": "1.30.1",
57
+ "@modelcontextprotocol/sdk": "1.32.1",
53
58
  "playwright": "1.63.0"
54
59
  },
55
60
  "devDependencies": {