agent-device-lab 0.0.0-stage → 0.3.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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +142 -2
  4. package/assets/dashboard/app.css +238 -0
  5. package/assets/dashboard/app.js +976 -0
  6. package/assets/dashboard/index.html +120 -0
  7. package/bin/agentlab.js +8 -0
  8. package/dist/cli/ci.js +443 -0
  9. package/dist/cli/flow.js +127 -0
  10. package/dist/cli/main.js +855 -0
  11. package/dist/cli/signals.js +38 -0
  12. package/dist/core/action-log.js +125 -0
  13. package/dist/core/auth.js +175 -0
  14. package/dist/core/bundle.js +432 -0
  15. package/dist/core/checks.js +137 -0
  16. package/dist/core/ci.js +389 -0
  17. package/dist/core/commands.js +366 -0
  18. package/dist/core/control.js +217 -0
  19. package/dist/core/detectors.js +392 -0
  20. package/dist/core/devices.js +83 -0
  21. package/dist/core/doctor.js +203 -0
  22. package/dist/core/explore-safety.js +110 -0
  23. package/dist/core/extract.js +1445 -0
  24. package/dist/core/feed.js +493 -0
  25. package/dist/core/findings.js +179 -0
  26. package/dist/core/format.js +311 -0
  27. package/dist/core/init.js +607 -0
  28. package/dist/core/lab.js +1979 -0
  29. package/dist/core/migrate.js +35 -0
  30. package/dist/core/observation.js +161 -0
  31. package/dist/core/ownership.js +142 -0
  32. package/dist/core/process-group.js +206 -0
  33. package/dist/core/process-identity.js +56 -0
  34. package/dist/core/profile.js +816 -0
  35. package/dist/core/project-runner.js +359 -0
  36. package/dist/core/replay.js +303 -0
  37. package/dist/core/reproduction.js +36 -0
  38. package/dist/core/scan-policy.js +156 -0
  39. package/dist/core/scan-report.js +145 -0
  40. package/dist/core/scan.js +455 -0
  41. package/dist/core/schema.js +39 -0
  42. package/dist/core/services.js +134 -0
  43. package/dist/core/steps.js +80 -0
  44. package/dist/core/sweep.js +138 -0
  45. package/dist/core/trace-sanitize.js +641 -0
  46. package/dist/core/url-redact.js +25 -0
  47. package/dist/core/versions.js +29 -0
  48. package/dist/core/zip.js +264 -0
  49. package/dist/daemon/main.js +160 -0
  50. package/dist/daemon/state.js +93 -0
  51. package/dist/dashboard/open.js +25 -0
  52. package/dist/dashboard/server.js +366 -0
  53. package/dist/mcp/server.js +89 -0
  54. package/package.json +58 -4
@@ -0,0 +1,855 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
4
+ import { basename, dirname, join, resolve } from 'node:path';
5
+ import { createInterface } from 'node:readline/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { parseArgs } from 'node:util';
8
+ import { DEVICES } from '../core/devices.js';
9
+ import { formatError, formatScan } from '../core/format.js';
10
+ import { scanSummary } from '../core/scan.js';
11
+ import { clearState, daemonCheck, paths, readDashboardRecord, readState, rpc, stateDir, userStateRoot } from '../daemon/state.js';
12
+ import { runCi, runReport } from './ci.js';
13
+ import { runFlow } from './flow.js';
14
+ import { holdIfTerminating, onTermination } from './signals.js';
15
+ import { keepRuns, liveSessionIds, pruneRuns, reapOrphans, stopRecordedServices } from '../core/ownership.js';
16
+ import { runMcpServer } from '../mcp/server.js';
17
+ import { SessionFeed } from '../core/feed.js';
18
+ import { Lab } from '../core/lab.js';
19
+ import { Dashboard } from '../dashboard/server.js';
20
+ import { hasDisplay, openInBrowser } from '../dashboard/open.js';
21
+ import { formatProposal, proposeProfile, writeProposal } from '../core/init.js';
22
+ import { formatDoctor, runDoctor } from '../core/doctor.js';
23
+ import { authStatus } from '../core/auth.js';
24
+ import { PROFILE_FILE, loadProfile } from '../core/profile.js';
25
+ import { migrateProfile } from '../core/migrate.js';
26
+ import { listBundles, loadBundle, pruneBundles, removeBundle, retentionLimits } from '../core/bundle.js';
27
+ import { formatReplay, replayBundle, replayExitCode } from '../core/replay.js';
28
+ import { LabError } from '../core/schema.js';
29
+ import { CONTRACT_VERSIONS, productVersion } from '../core/versions.js';
30
+ const HELP = `agentlab: launch a project, drive it in a mobile browser, and see what changed.
31
+
32
+ Setup:
33
+ agentlab init [dir] [--yes | --print] [--force]
34
+ inspect the project and propose agentlab.json (shown before saving)
35
+ agentlab doctor [--project <dir>] [--no-launch]
36
+ check Node.js, the browser, display, permissions, ports and the profile
37
+ agentlab migrate [--project <dir|file>] [--yes | --print] [--force]
38
+ rewrite an older agentlab.json as schemaVersion 2 (keeps agentlab.json.v1.bak)
39
+ agentlab clean stop services a crashed session left running and prune old runs (AGENTLAB_KEEP_RUNS, default 20)
40
+ agentlab install-browser [--with-deps] install the Chromium build this version of Playwright needs
41
+ agentlab version
42
+
43
+ Session (a background daemon per directory keeps the browser open between commands):
44
+ agentlab start [--project <dir>] [--device mobile-390] [--headed] [--slow-mo <ms>] [--auth auto|saved|fresh] [--trace]
45
+ agentlab observe [--limit <n>]
46
+ agentlab click <ref> e.g. agentlab click e3; or --name <name> [--role <role>] for any targeted action
47
+ agentlab fill <ref> <text>
48
+ agentlab press <key> [ref] Enter, Escape, Tab, Shift+Tab, Control+a …
49
+ agentlab select <ref> <option>... by label or value (native <select>)
50
+ agentlab check <ref> | uncheck <ref>
51
+ agentlab scroll [up|down|left|right] [ref] [--amount <px>] no direction: bring <ref> into view
52
+ agentlab swipe <left|right|up|down> [ref] [--amount <px>] touch swipe (the finger's direction)
53
+ agentlab back | forward
54
+ agentlab hover <ref>
55
+ agentlab upload <ref> <file>... project-relative files inside uploads.allow
56
+ agentlab drag <ref> (--to <ref> | --dx <px> --dy <px>)
57
+ agentlab tabs list tabs; tab open </path> | tab switch <t2> | tab close [t2]
58
+ agentlab inspect [F2 | e10] session findings with evidence and reproduction steps
59
+ agentlab sweep [/route] [--devices mobile-320,tablet-768]
60
+ responsive sweep at 320/390/768/1440 px, one isolated context each
61
+ agentlab scan [/route] [--scenario <a,b>] [--devices <ids>] [--explore | --no-explore]
62
+ stateful scan of agentlab.json scan.scenarios in the running session
63
+ (menus, drawers, dialogs…); exit 1 when the scan policy fails
64
+ agentlab auth save | status | clear saved sign-in state (owner-only, git-ignored, never printed)
65
+ agentlab bundle [--note <why>] write a secret-free failure bundle for the running session
66
+ agentlab status
67
+ agentlab log [-f] ordered action log for the running session
68
+ agentlab stop close the browser; stop only the services the lab started
69
+
70
+ Live dashboard (served by the session on 127.0.0.1 with a per-session token; --no-ui disables it):
71
+ agentlab ui [--no-open] print the dashboard URL and open it when a display is available
72
+
73
+ MCP (stdio server over the same commands; start returns the dashboard URL):
74
+ agentlab mcp [--headed] [--no-ui]
75
+
76
+ Scripted:
77
+ agentlab run <flow.json> [--headed] [--device <id>] [--slow-mo <ms>] [--ui [--no-open]]
78
+ agentlab scan --project <dir> [/route] [--scenario <a,b>] [--devices <ids>] [--explore] [--headed] [--auth auto|saved|fresh] [--ui [--no-open]]
79
+ start, scan, stop: HTML and JSON reports; exit 0 pass, 1 policy fail, 2 could not run
80
+ agentlab devices
81
+
82
+ CI (non-interactive, provider-neutral; docs/ci.md). Each starts the project, runs, and stops only what it started:
83
+ agentlab test [--project <dir>] everything the profile's "ci" section selects (flows, routes to sweep, scenarios to scan)
84
+ agentlab sweep --project <dir> [/route ...] CI sweep of routes (without --project: the running session, as above)
85
+ agentlab scenario --project <dir> [name ...] CI scan of declared scenarios (without --project: scan in the running session)
86
+ agentlab report <out-dir|ci-result.json> [--format text|json|html|junit] [--out <file>]
87
+ re-render a saved result; no browser
88
+ shared flags: --devices a,b --flows a.json,b.json --routes /a,/b --scenarios A,B --fail-on high|medium|low|none
89
+ --fail-on-heuristic --scenario-errors fail|report (default fail) --out <dir> (default ./agentlab-results)
90
+ --format text,json,html,junit (default all; ci-result.json is always written) --trace off|on-failure|always (default off)
91
+ --evidence off|on-failure|always (default on-failure) --timeout 90s|15m (default 30m)
92
+ --auth fresh|saved|env (env: AGENTLAB_AUTH_STATE; default env when set, else fresh)
93
+ --validate-only --headed --json (print the result JSON instead of the text report)
94
+ exit codes: 0 pass, 1 policy failed (a flow failed, or a finding at/above --fail-on), 2 could not run (invalid profile, missing env or
95
+ browser, unknown selection, start failure), 3 timed out (partial result written), 130/143/129 cancelled by SIGINT/SIGTERM/SIGHUP
96
+ (services stopped, partial result written). Artifacts in --out: ci-result.json, summary.txt, report.html, junit.xml, scan/,
97
+ frames/, bundles/, traces/. Secrets are redacted from every output; a leak aborts the write with exit 2.
98
+
99
+ Failure bundles (under <state>/bundles; kept 14 days, newest 20; AGENTLAB_BUNDLE_DAYS, AGENTLAB_KEEP_BUNDLES):
100
+ agentlab bundles [list] | show <id> | rm <id> | prune [--older-than <days>] [--dir <bundles dir>]
101
+ agentlab replay <bundle dir|bundle.json> [--project <dir>] [--headed] [--allow-consequential] [--secret <step>=<ENV_NAME>]... [--until <step>]
102
+ re-run the recorded actions against the live project: exit 0 reproduced, 1 not reproduced,
103
+ 2 could not run, 3 diverged or blocked. start --trace records a sanitized trace for bundles.
104
+
105
+ Every command accepts --json for the full structured result.
106
+ State lives in ./.agentlab in an initialised project, otherwise under ${'$'}XDG_STATE_HOME/agentlab
107
+ (override with AGENTLAB_HOME).`;
108
+ // `--trace` is a switch for `start`, and takes off|on-failure|always for the CI commands: the second form is
109
+ // renamed before parsing so both keep working.
110
+ const TRACE_MODES = ['off', 'on-failure', 'always'];
111
+ const argv = process.argv.slice(2).flatMap((a, i, all) => {
112
+ if (a.startsWith('--trace='))
113
+ return [`--trace-mode=${a.slice(8)}`];
114
+ if (a === '--trace' && TRACE_MODES.includes(all[i + 1] ?? ''))
115
+ return ['--trace-mode'];
116
+ return [a];
117
+ });
118
+ const { values: flags, positionals } = parseArgs({
119
+ args: argv,
120
+ allowPositionals: true,
121
+ allowNegative: true,
122
+ options: {
123
+ project: { type: 'string' },
124
+ device: { type: 'string' },
125
+ headless: { type: 'boolean', default: false },
126
+ 'slow-mo': { type: 'string' },
127
+ limit: { type: 'string' },
128
+ role: { type: 'string' },
129
+ devices: { type: 'string' },
130
+ scenario: { type: 'string' },
131
+ explore: { type: 'boolean' },
132
+ headed: { type: 'boolean', default: false },
133
+ name: { type: 'string' },
134
+ auth: { type: 'string' },
135
+ amount: { type: 'string' },
136
+ to: { type: 'string' },
137
+ dx: { type: 'string' },
138
+ dy: { type: 'string' },
139
+ yes: { type: 'boolean', short: 'y', default: false },
140
+ print: { type: 'boolean', default: false },
141
+ force: { type: 'boolean', default: false },
142
+ launch: { type: 'boolean', default: true },
143
+ 'with-deps': { type: 'boolean', default: false },
144
+ json: { type: 'boolean', default: false },
145
+ follow: { type: 'boolean', short: 'f', default: false },
146
+ ui: { type: 'boolean' },
147
+ trace: { type: 'boolean', default: false },
148
+ 'trace-mode': { type: 'string' },
149
+ flows: { type: 'string' },
150
+ routes: { type: 'string' },
151
+ scenarios: { type: 'string' },
152
+ 'fail-on': { type: 'string' },
153
+ 'fail-on-heuristic': { type: 'boolean' },
154
+ 'scenario-errors': { type: 'string' },
155
+ out: { type: 'string' },
156
+ format: { type: 'string' },
157
+ evidence: { type: 'string' },
158
+ timeout: { type: 'string' },
159
+ 'validate-only': { type: 'boolean', default: false },
160
+ note: { type: 'string' },
161
+ dir: { type: 'string' },
162
+ 'older-than': { type: 'string' },
163
+ until: { type: 'string' },
164
+ secret: { type: 'string', multiple: true },
165
+ 'allow-consequential': { type: 'boolean', default: false },
166
+ open: { type: 'boolean', default: true },
167
+ version: { type: 'boolean', short: 'v', default: false },
168
+ help: { type: 'boolean', short: 'h', default: false },
169
+ },
170
+ });
171
+ const dir = stateDir();
172
+ const json = flags.json;
173
+ const require = createRequire(import.meta.url);
174
+ const PACKAGE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
175
+ function printResponse(res) {
176
+ if (json)
177
+ process.stdout.write(JSON.stringify(res.ok ? res.result : { error: res.error }, null, 2) + '\n');
178
+ else if (res.ok)
179
+ process.stdout.write((res.text ?? '') + '\n');
180
+ else
181
+ process.stderr.write(`error ${formatError(res.error)}\n`);
182
+ const r = res.result;
183
+ const actionFailed = res.ok && (r?.outcome === 'error' || (r?.verdict?.result !== undefined && r.verdict.result !== 'pass'));
184
+ process.exit(res.ok && !actionFailed ? 0 : 1);
185
+ }
186
+ function fail(error) {
187
+ printResponse({ ok: false, error });
188
+ }
189
+ function usage(message) {
190
+ fail({ code: 'invalid_request', message, hint: 'Run `agentlab help`.', recoverable: true });
191
+ }
192
+ const toNumber = (v, name, signed = false) => {
193
+ if (v === undefined)
194
+ return undefined;
195
+ const n = Number(v);
196
+ if (!Number.isFinite(n) || (!signed && n < 0))
197
+ usage(`--${name} must be a ${signed ? '' : 'non-negative '}number`);
198
+ return n;
199
+ };
200
+ async function call(command, args = {}) {
201
+ const state = readState(dir);
202
+ if (!state || daemonCheck(state) !== 'same') {
203
+ if (state)
204
+ clearState(dir);
205
+ fail({ code: 'no_session', message: 'No session is running in this directory', hint: 'Run `agentlab start --project <dir>` first.', recoverable: false });
206
+ }
207
+ try {
208
+ printResponse(await rpc(state.socket, command, args));
209
+ }
210
+ catch (err) {
211
+ fail({ code: 'no_session', message: `Cannot reach the session daemon: ${err.message}`, hint: 'Run `agentlab stop`, then start again.', recoverable: false });
212
+ }
213
+ }
214
+ /** --name/--role, or a ref taken from the positionals. */
215
+ function targetArgs(rest, required, command) {
216
+ if (flags.name !== undefined)
217
+ return { name: flags.name, ...(flags.role ? { role: flags.role } : {}) };
218
+ const ref = rest[0] && /^e\d+$/.test(rest[0]) ? rest.shift() : undefined;
219
+ if (!ref && required)
220
+ usage(`${command} needs a ref (e.g. e3) or --name`);
221
+ return ref ? { ref } : {};
222
+ }
223
+ async function start() {
224
+ if (flags.auth !== undefined && !['auto', 'saved', 'fresh'].includes(flags.auth))
225
+ usage('--auth must be auto, saved or fresh');
226
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
227
+ const existing = readState(dir);
228
+ if (existing && daemonCheck(existing) === 'same') {
229
+ fail({ code: 'session_exists', message: `A session daemon (pid ${existing.pid}) is already running here`, hint: 'Use it, or run `agentlab stop` first.', recoverable: false });
230
+ }
231
+ if (existing)
232
+ clearState(dir);
233
+ const log = openSync(paths(dir).log, 'a', 0o600);
234
+ const daemon = spawn(process.execPath, [fileURLToPath(new URL('../daemon/main.js', import.meta.url)), dir, ...(flags.ui === false ? ['--no-ui'] : [])], {
235
+ detached: true,
236
+ stdio: ['ignore', log, log],
237
+ env: process.env,
238
+ });
239
+ daemon.unref();
240
+ // Wait for the daemon's socket.
241
+ const deadline = Date.now() + 10_000;
242
+ let exited = false;
243
+ daemon.once('exit', () => { exited = true; });
244
+ while (!(readState(dir)?.pid === daemon.pid && existsSync(paths(dir).socket))) {
245
+ if (exited || Date.now() > deadline) {
246
+ fail({ code: 'startup_failed', message: `Session daemon did not start; see ${paths(dir).log}`, recoverable: false });
247
+ }
248
+ await new Promise((r) => setTimeout(r, 50));
249
+ }
250
+ const res = await rpc(paths(dir).socket, 'start', {
251
+ project: resolve(flags.project ?? '.'),
252
+ ...(flags.device ? { device: flags.device } : {}),
253
+ headed: flags.headed === true,
254
+ ...(flags['slow-mo'] ? { slowMoMs: toNumber(flags['slow-mo'], 'slow-mo') } : {}),
255
+ ...(flags.auth ? { auth: flags.auth } : {}),
256
+ ...(flags.trace ? { trace: true } : {}),
257
+ });
258
+ if (res.ok && !json)
259
+ res.text += `\n\naction log: agentlab log -f (or tail -f ${paths(dir).log})${flags.ui === false ? '' : '\nwatch live: agentlab ui'}`;
260
+ printResponse(res);
261
+ }
262
+ function showDashboard(url, open, write) {
263
+ write(`dashboard: ${url}\n`);
264
+ if (!open)
265
+ return;
266
+ if (openInBrowser(url))
267
+ write('opened in your browser\n');
268
+ else
269
+ write(`no desktop display detected (DISPLAY/WAYLAND_DISPLAY unset); open the URL in a browser on this machine,\nor forward the port: ssh -L ${new URL(url).port}:127.0.0.1:${new URL(url).port} <this host>\n`);
270
+ }
271
+ async function ui() {
272
+ const state = readState(dir);
273
+ if (!state || daemonCheck(state) !== 'same') {
274
+ // No CLI session here: an MCP server started from this directory may be serving one.
275
+ const mcp = readDashboardRecord(dir);
276
+ if (mcp) {
277
+ if (json)
278
+ process.stdout.write(JSON.stringify({ url: mcp.url, source: 'mcp', opened: flags.open && hasDisplay() && openInBrowser(mcp.url) }) + '\n');
279
+ else
280
+ showDashboard(mcp.url, flags.open, (s) => process.stdout.write(s));
281
+ process.exit(0);
282
+ }
283
+ fail({ code: 'no_session', message: 'No session is running in this directory', hint: 'Run `agentlab start --project <dir>` first (or run this where the MCP server was started).', recoverable: false });
284
+ }
285
+ if (!state.dashboard) {
286
+ fail({ code: 'invalid_request', message: 'This session was started with --no-ui, so it has no dashboard', hint: 'Stop it and start again without --no-ui.', recoverable: false });
287
+ }
288
+ if (json)
289
+ process.stdout.write(JSON.stringify({ url: state.dashboard.url, opened: flags.open && hasDisplay() && openInBrowser(state.dashboard.url) }) + '\n');
290
+ else
291
+ showDashboard(state.dashboard.url, flags.open, (s) => process.stdout.write(s));
292
+ process.exit(0);
293
+ }
294
+ /** `run --ui`: serve the dashboard for this in-process flow and give a viewer a chance to connect first. */
295
+ async function runWithDashboard(flow) {
296
+ const feed = new SessionFeed();
297
+ let lab;
298
+ const dashboard = await Dashboard.listen({
299
+ feed, source: () => lab,
300
+ control: (op, by) => { if (!lab)
301
+ throw new LabError('no_session', 'The flow has not started yet'); return lab.supervise(op, by); },
302
+ input: (i) => { if (!lab)
303
+ throw new LabError('no_session', 'The flow has not started yet'); return lab.humanInput(i); },
304
+ });
305
+ const err = (s) => process.stderr.write(s);
306
+ onTermination(async (signal) => { await lab?.close(`interrupted by ${signal}`); await dashboard.close().catch(() => undefined); }, { stateDir: dir });
307
+ showDashboard(dashboard.url, flags.open, err);
308
+ const deadline = Date.now() + 30_000;
309
+ while (!dashboard.getStats().viewers.events && Date.now() < deadline)
310
+ await new Promise((r) => setTimeout(r, 100));
311
+ err(dashboard.getStats().viewers.events ? 'dashboard connected; running\n' : 'no dashboard connected after 30 s; running anyway\n');
312
+ try {
313
+ return await runFlow(flow, {
314
+ headed: flags.headed === true, device: flags.device, slowMoMs: toNumber(flags['slow-mo'], 'slow-mo'), json, stateDir: dir,
315
+ lab: { onEvent: (e) => feed.apply(e), evidenceFrames: true }, onLab: (l) => { lab = l; },
316
+ });
317
+ }
318
+ finally {
319
+ await holdIfTerminating();
320
+ await new Promise((r) => setTimeout(r, 300)); // let the final status reach the viewer
321
+ await dashboard.close();
322
+ }
323
+ }
324
+ function scanArgs(route) {
325
+ return {
326
+ ...(route ? { route } : {}), ...(flags.scenario ? { scenarios: flags.scenario } : {}), ...(flags.devices ? { devices: flags.devices } : {}),
327
+ ...(flags.explore !== undefined ? { explore: flags.explore } : {}),
328
+ };
329
+ }
330
+ /**
331
+ * `scan --project`: start the project (headless unless --headed), scan it, stop what was started.
332
+ * Exit 0 when the policy passes, 1 when it fails, 2 when the scan could not run.
333
+ */
334
+ async function scanStandalone(route) {
335
+ if (flags.auth !== undefined && !['auto', 'saved', 'fresh'].includes(flags.auth))
336
+ usage('--auth must be auto, saved or fresh');
337
+ const feed = flags.ui ? new SessionFeed() : undefined;
338
+ let lab;
339
+ const dashboard = feed ? await Dashboard.listen({
340
+ feed, source: () => lab,
341
+ control: (op, by) => lab.supervise(op, by),
342
+ input: (i) => lab.humanInput(i),
343
+ }) : undefined;
344
+ if (dashboard)
345
+ showDashboard(dashboard.url, flags.open, (s) => process.stderr.write(s));
346
+ lab = new Lab({ stateDir: dir, ...(feed ? { onEvent: (e) => feed.apply(e), evidenceFrames: true } : {}) });
347
+ let code = 2;
348
+ onTermination(async (signal) => { await lab?.close(`interrupted by ${signal}`); await dashboard?.close().catch(() => undefined); }, { stateDir: dir });
349
+ try {
350
+ await lab.start({
351
+ project: resolve(flags.project), ...(flags.device ? { device: flags.device } : {}), headed: flags.headed === true,
352
+ ...(flags.auth ? { auth: flags.auth } : {}),
353
+ });
354
+ const a = scanArgs(route);
355
+ const list = (v) => typeof v === 'string' ? v.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
356
+ const result = await lab.scan({ route: a.route, scenarios: list(a.scenarios), devices: list(a.devices), explore: a.explore });
357
+ process.stdout.write(json ? JSON.stringify(scanSummary(result), null, 2) + '\n' : formatScan(result) + '\n');
358
+ code = result.verdict.result === 'pass' ? 0 : 1;
359
+ }
360
+ catch (err) {
361
+ process.stderr.write(`error ${formatError(LabError.from(err).toJSON())}\n`);
362
+ }
363
+ finally {
364
+ await lab.close('scan finished');
365
+ if (dashboard) {
366
+ await new Promise((r) => setTimeout(r, 300));
367
+ await dashboard.close();
368
+ }
369
+ }
370
+ await holdIfTerminating();
371
+ process.exit(code);
372
+ }
373
+ /** The CI commands (`test`, `sweep --project`, `scenario --project`): see docs/ci.md. */
374
+ function ci(command, positional) {
375
+ return runCi({
376
+ command, positionals: positional, project: resolve(flags.project ?? '.'), stateDir: dir, validateOnly: flags['validate-only'], headed: flags.headed === true, json,
377
+ ...(flags.devices ? { devices: flags.devices } : {}), ...(flags.flows !== undefined ? { flows: flags.flows } : {}),
378
+ ...(flags.routes !== undefined ? { routes: flags.routes } : {}), ...(flags.scenarios !== undefined ? { scenarios: flags.scenarios } : {}),
379
+ ...(flags['fail-on'] ? { failOn: flags['fail-on'] } : {}), ...(flags['fail-on-heuristic'] !== undefined ? { failOnHeuristic: flags['fail-on-heuristic'] } : {}),
380
+ ...(flags['scenario-errors'] ? { scenarioErrors: flags['scenario-errors'] } : {}), ...(flags.out ? { out: flags.out } : {}),
381
+ ...(flags.format ? { format: flags.format } : {}), ...(flags['trace-mode'] ? { trace: flags['trace-mode'] } : {}),
382
+ ...(flags.evidence ? { evidence: flags.evidence } : {}), ...(flags.timeout ? { timeout: flags.timeout } : {}), ...(flags.auth ? { auth: flags.auth } : {}),
383
+ });
384
+ }
385
+ async function stop() {
386
+ const state = readState(dir);
387
+ if (!state) {
388
+ const notes = await reapOrphans(dir);
389
+ process.stdout.write(['no session running', ...notes].join('; ') + '\n');
390
+ process.exit(0);
391
+ }
392
+ const daemon = daemonCheck(state);
393
+ if (daemon === 'same') {
394
+ try {
395
+ const res = await rpc(state.socket, 'stop');
396
+ // Wait for the daemon to exit so a following `start` never races it.
397
+ for (let i = 0; i < 100 && daemonCheck(state) === 'same'; i++)
398
+ await new Promise((r) => setTimeout(r, 50));
399
+ printResponse(res);
400
+ }
401
+ catch { /* unreachable daemon: fall through to recovery */ }
402
+ if (daemonCheck(state) === 'same') {
403
+ try {
404
+ process.kill(state.pid, 'SIGTERM');
405
+ }
406
+ catch { /* gone */ }
407
+ }
408
+ }
409
+ const notes = [describeDaemon(state, daemon), ...await recoverServices(state)];
410
+ clearState(dir);
411
+ notes.push(...await reapOrphans(dir));
412
+ process.stdout.write(notes.join('; ') + '\n');
413
+ process.exit(0);
414
+ }
415
+ function describeDaemon(state, check) {
416
+ switch (check) {
417
+ case 'same': return `session daemon ${state.pid} did not answer and was sent SIGTERM`;
418
+ case 'gone': return `session daemon ${state.pid} had already exited`;
419
+ case 'reused': return `stale record: pid ${state.pid} now belongs to another process, which was not signalled`;
420
+ case 'unverifiable': return `record for pid ${state.pid} has no process identity (older format); nothing was signalled`;
421
+ }
422
+ }
423
+ /**
424
+ * Recovery for a daemon that died without cleanup: stop only owned process groups whose identity still
425
+ * matches. A one-shot service's stop command is named for the person to run; its containers cannot be
426
+ * proved to still be the ones the lab started.
427
+ */
428
+ async function recoverServices(state) {
429
+ const records = state.services ?? (state.server ? [{ name: 'web', mode: 'process', ...state.server }] : []);
430
+ return stopRecordedServices(records);
431
+ }
432
+ /** `clean`: stop what crashed sessions left running (ownership records) and prune old runs. */
433
+ async function clean() {
434
+ const notes = await reapOrphans(dir);
435
+ const state = readState(dir);
436
+ const removed = pruneRuns(dir, keepRuns(), liveSessionIds(dir).add(state?.sessionId ?? ''));
437
+ const lines = [...notes, removed.length ? `pruned ${removed.length} old run${removed.length > 1 ? 's' : ''} (keeping the latest ${keepRuns()})` : 'no old runs to prune'];
438
+ if (!notes.length)
439
+ lines.unshift('no services left behind by earlier sessions');
440
+ if (json)
441
+ process.stdout.write(JSON.stringify({ reaped: notes, prunedRuns: removed }, null, 2) + '\n');
442
+ else
443
+ process.stdout.write(lines.join('\n') + '\n');
444
+ process.exit(0);
445
+ }
446
+ function contractsLine() {
447
+ const c = CONTRACT_VERSIONS;
448
+ return `contracts: profile ${c.profile} (reads ${c.profileSupported[0]}–${c.profileSupported.at(-1)}), results ${c.results}, mcp tools ${c.mcpTools}, report ${c.report}, bundle ${c.bundle}, events ${c.events}`;
449
+ }
450
+ async function version() {
451
+ const pwDir = dirname(require.resolve('playwright/package.json'));
452
+ const pw = JSON.parse(readFileSync(join(pwDir, 'package.json'), 'utf8')).version;
453
+ const { chromium } = await import('playwright');
454
+ const exe = chromium.executablePath();
455
+ const info = { agentlab: productVersion(), node: process.versions.node, playwright: pw, chromium: { executable: exe, installed: existsSync(exe) }, installedAt: PACKAGE_ROOT, stateDir: dir, contracts: CONTRACT_VERSIONS };
456
+ if (json)
457
+ process.stdout.write(JSON.stringify(info, null, 2) + '\n');
458
+ else {
459
+ process.stdout.write(`agentlab ${info.agentlab}\nnode ${info.node}\nplaywright ${pw}; chromium ${info.chromium.installed ? 'installed' : 'NOT installed (run `agentlab install-browser`)'} at ${exe}\ninstalled at ${PACKAGE_ROOT}\n${contractsLine()}\n`);
460
+ }
461
+ process.exit(0);
462
+ }
463
+ function installBrowser() {
464
+ // Playwright's own CLI from this package's dependency, so the browser build matches the pinned version.
465
+ const cli = join(dirname(require.resolve('playwright/package.json')), 'cli.js');
466
+ const args = [cli, 'install', ...(flags['with-deps'] ? ['--with-deps'] : []), 'chromium'];
467
+ process.stderr.write(`running: ${process.execPath} ${args.join(' ')}\n`);
468
+ const r = spawnSync(process.execPath, args, { stdio: 'inherit' });
469
+ process.exit(r.status ?? 1);
470
+ }
471
+ async function doctor() {
472
+ const state = readState(dir);
473
+ const report = await runDoctor({
474
+ cwd: process.cwd(), ...(flags.project ? { project: resolve(flags.project) } : {}), stateDir: dir, launch: flags.launch,
475
+ ...(state ? { daemon: { pid: state.pid, check: daemonCheck(state) } } : {}),
476
+ });
477
+ process.stdout.write(json ? JSON.stringify(report, null, 2) + '\n' : `agentlab ${productVersion()} doctor\n${formatDoctor(report)}\n`);
478
+ process.exit(report.ok ? 0 : 1);
479
+ }
480
+ async function init(target) {
481
+ const project = resolve(target ?? '.');
482
+ const proposal = await proposeProfile(project);
483
+ if (json && (flags.print || !flags.yes)) {
484
+ process.stdout.write(JSON.stringify(proposal, null, 2) + '\n');
485
+ process.exit(0);
486
+ }
487
+ if (!json)
488
+ process.stdout.write(formatProposal(proposal) + '\n');
489
+ if (flags.print)
490
+ process.exit(0);
491
+ if (proposal.existing && !flags.force) {
492
+ process.stdout.write(`\nagentlab.json already exists; nothing written. Re-run with --force to replace it.\n`);
493
+ process.exit(0);
494
+ }
495
+ let write = flags.yes;
496
+ if (!write) {
497
+ if (!process.stdin.isTTY) {
498
+ process.stdout.write('\nNothing written (not interactive). Review the proposal, then re-run with --yes to save it.\n');
499
+ process.exit(0);
500
+ }
501
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
502
+ const answer = await rl.question(`\nWrite ${join(project, 'agentlab.json')}${proposal.gitignore.ignoresState ? '' : ' and add .agentlab/ to .gitignore'}? [y/N] `);
503
+ rl.close();
504
+ write = /^y(es)?$/i.test(answer.trim());
505
+ }
506
+ if (!write) {
507
+ process.stdout.write('Nothing written.\n');
508
+ process.exit(0);
509
+ }
510
+ const saved = await writeProposal(proposal, { force: flags.force });
511
+ const out = { written: saved.profilePath, gitignoreUpdated: saved.gitignoreUpdated };
512
+ process.stdout.write(json ? JSON.stringify(out) + '\n'
513
+ : `wrote ${saved.profilePath}${saved.gitignoreUpdated ? '; added .agentlab/ to .gitignore' : ''}\nnext: agentlab doctor, then agentlab start\n`);
514
+ process.exit(0);
515
+ }
516
+ /** A copy of a profile for display, with env values whose names look secret replaced. */
517
+ function maskSecretEnv(profile) {
518
+ const copy = structuredClone(profile);
519
+ const services = (copy.services ?? {});
520
+ for (const svc of [...Object.values(services), copy.web]) {
521
+ for (const k of Object.keys(svc?.env ?? {}))
522
+ if (SECRET_NAME.test(k))
523
+ svc.env[k] = '‹redacted›';
524
+ }
525
+ return copy;
526
+ }
527
+ const SECRET_NAME = /secret|token|passw|api[_-]?key|auth|cookie|session|private|credential/i;
528
+ function bundlesDir() {
529
+ return resolve(flags.dir ?? join(dir, 'bundles'));
530
+ }
531
+ /** `agentlab bundles [list] | show <id> | rm <id> | prune`. */
532
+ async function bundles(sub = 'list', id) {
533
+ const root = bundlesDir();
534
+ const say = (s) => process.stdout.write(s + '\n');
535
+ try {
536
+ switch (sub) {
537
+ case 'list': {
538
+ const all = listBundles(root);
539
+ if (json)
540
+ say(JSON.stringify({ dir: root, bundles: all }, null, 2));
541
+ else if (!all.length)
542
+ say(`no bundles in ${root}`);
543
+ else
544
+ for (const b of all)
545
+ say(`${b.id} ${b.createdAt.slice(0, 19)} ${b.failure.padEnd(14)} ${b.actions} actions, ${b.findings} findings${b.trace ? ', trace' : ''} ${b.reason.slice(0, 80)}`);
546
+ break;
547
+ }
548
+ case 'show': {
549
+ if (!id)
550
+ return usage('bundles show needs a bundle id');
551
+ const b = loadBundle(join(root, id));
552
+ if (json) {
553
+ say(JSON.stringify(b, null, 2));
554
+ break;
555
+ }
556
+ say(`${b.id} ${b.retention.createdAt} (expires ${b.retention.expiresAt.slice(0, 10)})\nreason: ${b.reason}\nfailure: ${JSON.stringify(b.failure)}`);
557
+ say(`project ${b.project.name} device ${b.session.device} auth ${b.session.auth} start ${b.session.startRoute} route now ${b.route}`);
558
+ say(`agentlab ${b.product.version} ${b.environment.platform}/${b.environment.arch} node ${b.environment.node} chromium ${b.environment.chromium}${b.environment.ci ? ' CI' : ''}`);
559
+ for (const a of b.actions) {
560
+ say(a.actor === 'person' ? ` ${a.index} a person: ${a.description}`
561
+ : ` ${a.index} ${a.action}${a.target ? ` ${a.target.role} ${JSON.stringify(a.target.name)}` : ''}${a.secret ? ' ‹secret›' : ''} ${a.routeBefore ?? '?'} → ${a.routeAfter ?? '?'} ${a.outcome === 'error' ? `ERROR ${a.error?.code}` : 'ok'}${a.consequential ? ' (consequential)' : ''}`);
562
+ }
563
+ if (b.actionsOmitted)
564
+ say(` (${b.actionsOmitted} earlier action(s) dropped from the log)`);
565
+ say(`${b.findings.length} findings, ${b.consoleErrors.length} console errors, ${b.failedRequests.length} failed requests, ${b.frames.length} frames${b.trace ? ', sanitized trace' : b.traceDropped ? `, trace left out (${b.traceDropped})` : ''}`);
566
+ break;
567
+ }
568
+ case 'rm':
569
+ if (!id)
570
+ return usage('bundles rm needs a bundle id');
571
+ removeBundle(root, id);
572
+ say(`removed ${id}`);
573
+ break;
574
+ case 'prune': {
575
+ const days = toNumber(flags['older-than'], 'older-than');
576
+ const removed = pruneBundles(root, { ...(days !== undefined ? { maxAgeDays: days } : {}) });
577
+ const limits = retentionLimits();
578
+ say(json ? JSON.stringify({ removed }) : `removed ${removed.length} bundle(s)${removed.length ? `: ${removed.join(', ')}` : ''} (keeping the newest ${limits.keep}, at most ${days ?? limits.maxAgeDays} days old)`);
579
+ break;
580
+ }
581
+ default: return usage('bundles takes list, show <id>, rm <id> or prune');
582
+ }
583
+ }
584
+ catch (err) {
585
+ process.stderr.write(`error ${formatError(LabError.from(err).toJSON())}\n`);
586
+ process.exit(2);
587
+ }
588
+ process.exit(0);
589
+ }
590
+ /** `agentlab replay <bundle>`: exit 0 reproduced, 1 not reproduced, 2 could not run, 3 diverged or blocked. */
591
+ async function replay(target) {
592
+ if (!target)
593
+ return usage('replay needs a bundle directory or bundle.json');
594
+ const secrets = {};
595
+ for (const spec of flags.secret ?? []) {
596
+ const m = /^(\d+)=(.+)$/.exec(spec);
597
+ if (!m)
598
+ return usage('--secret takes <step>=<ENV_NAME>, e.g. --secret 2=MY_PASSWORD');
599
+ secrets[Number(m[1])] = m[2];
600
+ }
601
+ const until = toNumber(flags.until, 'until');
602
+ let running;
603
+ onTermination(async (signal) => { await running?.close(`interrupted by ${signal}`); }, { stateDir: dir });
604
+ try {
605
+ const result = await replayBundle({
606
+ bundle: target, ...(flags.project ? { project: flags.project } : {}), stateDir: dir, headed: flags.headed,
607
+ allowConsequential: flags['allow-consequential'], secrets, ...(until !== undefined ? { until } : {}),
608
+ onLab: (l) => { running = l; },
609
+ });
610
+ await holdIfTerminating();
611
+ process.stdout.write(json ? JSON.stringify(result, null, 2) + '\n' : formatReplay(result) + '\n');
612
+ process.exit(replayExitCode(result));
613
+ }
614
+ catch (err) {
615
+ process.stderr.write(`error ${formatError(LabError.from(err).toJSON())}\n`);
616
+ process.exit(2);
617
+ }
618
+ }
619
+ async function migrate() {
620
+ let file = resolve(flags.project ?? '.');
621
+ if (existsSync(file) && statSync(file).isDirectory())
622
+ file = join(file, PROFILE_FILE);
623
+ let migration;
624
+ try {
625
+ let raw;
626
+ try {
627
+ raw = JSON.parse(readFileSync(file, 'utf8'));
628
+ }
629
+ catch (err) {
630
+ // Never echo the parser's message: V8 quotes the file's text around the error, which may hold values.
631
+ const at = /position (\d+)/.exec(err.message)?.[1];
632
+ const why = err.code ? `cannot read it (${err.code})` : `not valid JSON${at ? ` (at character ${at})` : ''}`;
633
+ throw new LabError('invalid_profile', `${file}: ${why}`, { hint: 'Pass --project <dir|file> for the agentlab.json to migrate.' });
634
+ }
635
+ migration = migrateProfile(raw);
636
+ }
637
+ catch (err) {
638
+ fail(LabError.from(err).toJSON());
639
+ }
640
+ const { changed, from, to, notes, profile } = migration;
641
+ if (!changed) {
642
+ process.stdout.write(json ? JSON.stringify({ changed, from, to }) + '\n' : `${basename(file)} is already schemaVersion ${to}; nothing to do\n`);
643
+ process.exit(0);
644
+ }
645
+ const text = JSON.stringify(profile, null, 2) + '\n';
646
+ const backup = `${file}.v1.bak`;
647
+ // What is shown masks secret-looking env values; the file written keeps them as they were.
648
+ const shown = maskSecretEnv(profile);
649
+ if (json && (flags.print || !flags.yes)) {
650
+ process.stdout.write(JSON.stringify({ changed, from, to, notes, profile: shown }, null, 2) + '\n');
651
+ process.exit(0);
652
+ }
653
+ if (!json)
654
+ process.stdout.write(`${file}: schemaVersion ${from} → ${to}\n${notes.map((n) => ` - ${n}`).join('\n')}\n\n${JSON.stringify(shown, null, 2)}\n`);
655
+ if (flags.print)
656
+ process.exit(0);
657
+ if (existsSync(backup) && !flags.force) {
658
+ fail({ code: 'invalid_request', message: `${backup} already exists; nothing written`, hint: 'Move it away, or re-run with --force to overwrite the backup.', recoverable: true });
659
+ }
660
+ let write = flags.yes;
661
+ if (!write) {
662
+ if (!process.stdin.isTTY) {
663
+ process.stdout.write('\nNothing written (not interactive). Review the result, then re-run with --yes to save it.\n');
664
+ process.exit(0);
665
+ }
666
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
667
+ const answer = await rl.question(`\nRewrite ${file} (backup: ${backup})? [y/N] `);
668
+ rl.close();
669
+ write = /^y(es)?$/i.test(answer.trim());
670
+ }
671
+ if (!write) {
672
+ process.stdout.write('Nothing written.\n');
673
+ process.exit(0);
674
+ }
675
+ // Back up the original bytes, then replace the file atomically (same directory, same mode).
676
+ copyFileSync(file, backup);
677
+ const tmp = `${file}.${process.pid}.tmp`;
678
+ writeFileSync(tmp, text);
679
+ chmodSync(tmp, statSync(file).mode & 0o777);
680
+ renameSync(tmp, file);
681
+ process.stdout.write(json ? JSON.stringify({ changed, from, to, notes, written: file, backup }) + '\n' : `wrote ${file}; original kept as ${backup}\nnext: agentlab doctor\n`);
682
+ process.exit(0);
683
+ }
684
+ async function auth(sub) {
685
+ if (sub === 'save')
686
+ return call('auth_save');
687
+ if (sub !== 'status' && sub !== 'clear')
688
+ usage('auth needs save, status or clear');
689
+ let profile;
690
+ try {
691
+ profile = await loadProfile(resolve(flags.project ?? '.'));
692
+ }
693
+ catch (err) {
694
+ fail(LabError.from(err).toJSON());
695
+ }
696
+ const status = authStatus(profile.auth, profile.root);
697
+ if (sub === 'clear') {
698
+ rmSync(profile.auth.file, { force: true });
699
+ process.stdout.write(json ? JSON.stringify({ cleared: status.exists, file: status.file }) + '\n' : status.exists ? `removed ${status.file}\n` : `no saved sign-in state at ${status.file}\n`);
700
+ process.exit(0);
701
+ }
702
+ if (json)
703
+ process.stdout.write(JSON.stringify(status, null, 2) + '\n');
704
+ else if (!status.exists)
705
+ process.stdout.write(`no saved sign-in state (${status.file})\n`);
706
+ else {
707
+ process.stdout.write(`${status.file}: ${status.cookies ?? '?'} cookies, ${status.origins ?? '?'} origins with storage, mode ${status.mode}${status.ownerOnly ? '' : ' (TOO OPEN)'}` +
708
+ `${status.expired ? ', expired' : ''}${status.ignored === false ? ', NOT ignored by git' : ''}${status.problem ? `, ${status.problem}` : ''}\n`);
709
+ }
710
+ process.exit(0);
711
+ }
712
+ async function main() {
713
+ const [command, ...rest] = positionals;
714
+ if (flags.version || command === 'version' || command === '--version')
715
+ return version();
716
+ if (flags.help || !command || command === 'help') {
717
+ process.stdout.write(HELP + '\n');
718
+ return;
719
+ }
720
+ switch (command) {
721
+ case 'init': return init(rest[0]);
722
+ case 'migrate': return migrate();
723
+ case 'doctor': return doctor();
724
+ case 'install-browser': return installBrowser();
725
+ case 'start': return start();
726
+ case 'stop':
727
+ case 'close': return stop();
728
+ case 'clean': return clean();
729
+ case 'ui': return ui();
730
+ case 'scan': return flags.project !== undefined ? scanStandalone(rest[0]) : call('scan', scanArgs(rest[0]));
731
+ case 'sweep':
732
+ return flags.project !== undefined ? ci('sweep', rest) : call('sweep', { ...(rest[0] ? { route: rest[0] } : {}), ...(flags.devices ? { devices: flags.devices } : {}) });
733
+ case 'scenario':
734
+ return flags.project !== undefined ? ci('scenario', rest) : call('scan', { ...(rest.length ? { scenarios: rest.join(',') } : {}), ...(flags.devices ? { devices: flags.devices } : {}) });
735
+ case 'test': return ci('test', rest);
736
+ case 'report': return runReport(rest[0], flags.format, flags.out);
737
+ case 'status': return call('status');
738
+ case 'bundle': return call('bundle', flags.note ? { note: flags.note } : {});
739
+ case 'bundles': return bundles(rest[0], rest[1]);
740
+ case 'replay': return replay(rest[0]);
741
+ case 'tabs': return call('tabs');
742
+ case 'tab': {
743
+ const [sub, arg] = rest;
744
+ if (sub === 'open')
745
+ return arg ? call('open_tab', { path: arg }) : usage('tab open needs a path, e.g. /help');
746
+ if (sub === 'switch')
747
+ return arg ? call('switch_tab', { tab: arg }) : usage('tab switch needs a tab id, e.g. t2');
748
+ if (sub === 'close')
749
+ return call('close_tab', arg ? { tab: arg } : {});
750
+ return usage('tab needs open, switch or close');
751
+ }
752
+ case 'auth': return auth(rest[0]);
753
+ case 'inspect': {
754
+ const [id] = rest;
755
+ return call('inspect', id ? (/^e\d+$/.test(id) ? { ref: id } : { id }) : {});
756
+ }
757
+ case 'observe': return call('observe', flags.limit ? { limit: toNumber(flags.limit, 'limit') } : {});
758
+ case 'click':
759
+ case 'fill':
760
+ case 'act': {
761
+ const action = command === 'act' ? rest.shift() : command;
762
+ if (action !== 'click' && action !== 'fill')
763
+ usage('act needs click or fill');
764
+ const target = targetArgs(rest, true, action);
765
+ const value = action === 'fill' ? rest.shift() : undefined;
766
+ if (action === 'fill' && value === undefined)
767
+ usage('fill needs the text to enter');
768
+ return call(action, { ...target, ...(value !== undefined ? { value } : {}) });
769
+ }
770
+ case 'press': {
771
+ const key = rest.shift();
772
+ if (!key)
773
+ usage('press needs a key, e.g. Enter');
774
+ return call('press', { key, ...targetArgs(rest, false, command) });
775
+ }
776
+ case 'select': {
777
+ const target = targetArgs(rest, true, command);
778
+ if (!rest.length)
779
+ usage('select needs at least one option label or value');
780
+ return call('select', { ...target, values: rest });
781
+ }
782
+ case 'check':
783
+ case 'uncheck':
784
+ case 'hover': return call(command, targetArgs(rest, true, command));
785
+ case 'scroll':
786
+ case 'swipe': {
787
+ const direction = rest[0] && ['up', 'down', 'left', 'right'].includes(rest[0]) ? rest.shift() : undefined;
788
+ if (command === 'swipe' && !direction)
789
+ usage('swipe needs a direction: left, right, up or down');
790
+ const target = targetArgs(rest, false, command);
791
+ if (!direction && !target.ref && !target.name)
792
+ usage('scroll needs a direction, a ref, or both');
793
+ return call(command, { ...(direction ? { direction } : {}), ...target, ...(flags.amount ? { amount: toNumber(flags.amount, 'amount') } : {}) });
794
+ }
795
+ case 'back':
796
+ case 'forward': return call(command);
797
+ case 'upload': {
798
+ const target = targetArgs(rest, true, command);
799
+ if (!rest.length)
800
+ usage('upload needs at least one file');
801
+ return call('upload', { ...target, files: rest });
802
+ }
803
+ case 'drag': {
804
+ const target = targetArgs(rest, true, command);
805
+ const toRef = flags.to ?? rest.shift();
806
+ if (!toRef && flags.dx === undefined && flags.dy === undefined)
807
+ usage('drag needs --to <ref>, or --dx/--dy');
808
+ return call('drag', { ...target, ...(toRef ? { toRef } : {}), ...(flags.dx !== undefined ? { dx: toNumber(flags.dx, 'dx', true) } : {}), ...(flags.dy !== undefined ? { dy: toNumber(flags.dy, 'dy', true) } : {}) });
809
+ }
810
+ case 'log': {
811
+ const file = paths(dir).log;
812
+ if (!existsSync(file)) {
813
+ process.stdout.write('no log yet\n');
814
+ return;
815
+ }
816
+ if (!flags.follow) {
817
+ process.stdout.write(readFileSync(file, 'utf8'));
818
+ return;
819
+ }
820
+ spawn('tail', ['-n', '+1', '-f', file], { stdio: 'inherit' });
821
+ return;
822
+ }
823
+ case 'mcp':
824
+ // stdout belongs to the MCP protocol from here on.
825
+ return runMcpServer({ stateDir: dir, headless: flags.headed !== true, ui: flags.ui !== false });
826
+ case 'devices':
827
+ for (const d of Object.values(DEVICES))
828
+ process.stdout.write(`${d.id}\t${d.label}\n`);
829
+ return;
830
+ case 'run': {
831
+ const flow = rest[0];
832
+ if (!flow)
833
+ usage('run needs a flow file');
834
+ let running;
835
+ if (!flags.ui)
836
+ onTermination(async (signal) => { await running?.close(`interrupted by ${signal}`); }, { stateDir: dir });
837
+ const ok = flags.ui ? await runWithDashboard(flow) : await runFlow(flow, {
838
+ headed: flags.headed === true, device: flags.device, slowMoMs: toNumber(flags['slow-mo'], 'slow-mo'), json, stateDir: dir,
839
+ onLab: (l) => { running = l; },
840
+ });
841
+ await holdIfTerminating();
842
+ process.exit(ok ? 0 : 1);
843
+ }
844
+ case 'state-dir':
845
+ process.stdout.write(`${dir}\n(user state root: ${userStateRoot()})\n`);
846
+ return;
847
+ default:
848
+ usage(`Unknown command "${command}"`);
849
+ }
850
+ }
851
+ main().catch((err) => {
852
+ process.stderr.write(`agentlab: ${err instanceof Error ? err.stack : String(err)}\n`);
853
+ process.exit(1);
854
+ });
855
+ //# sourceMappingURL=main.js.map