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,311 @@
1
+ import { MAX_FINDINGS } from './findings.js';
2
+ import { stopReason } from './control.js';
3
+ // Concise text renderings for agents and people. JSON output carries the full structure.
4
+ const q = (s) => JSON.stringify(s);
5
+ const MAX_CHANGE_ITEMS = 12;
6
+ const MAX_SCAN_GROUPS = 12;
7
+ export function formatControl(c) {
8
+ const parts = [c.ref, c.role, q(c.name)];
9
+ if (c.context)
10
+ parts.push(`in ${q(c.context)}`);
11
+ if (c.value !== undefined && c.value !== '')
12
+ parts.push(`value=${q(c.value)}`);
13
+ if (c.checked !== undefined)
14
+ parts.push(c.checked ? 'checked' : 'unchecked');
15
+ if (c.pressed !== undefined)
16
+ parts.push(c.pressed ? 'pressed' : 'not-pressed');
17
+ if (c.expanded !== undefined)
18
+ parts.push(c.expanded ? 'expanded' : 'collapsed');
19
+ for (const flag of ['disabled', 'required', 'invalid', 'focused'])
20
+ if (c[flag])
21
+ parts.push(flag);
22
+ if (c.offscreen)
23
+ parts.push(`offscreen-${c.offscreen}`);
24
+ if (c.clip)
25
+ parts.push(`CLIPPED-${c.clip.side} ${c.clip.px}px`);
26
+ return parts.join(' ');
27
+ }
28
+ export function formatObservation(o) {
29
+ const lines = [
30
+ `gen ${o.gen} route ${o.route} title ${q(o.title)} viewport ${o.viewport.width}x${o.viewport.height} scroll ${o.scroll.x},${o.scroll.y}`,
31
+ ];
32
+ if (o.headings.length)
33
+ lines.push(`headings: ${o.headings.map((h) => h.replace(/^(h\d) (.*)$/, (_, t, n) => `${t} ${q(n)}`)).join('; ')}`);
34
+ if (o.dialog !== undefined)
35
+ lines.push(`dialog: ${q(o.dialog)} (modal; ${o.inert.length} background controls inert)`);
36
+ lines.push(`controls (${o.controls.length}${o.omitted ? `, ${o.omitted} omitted by budget` : ''}):`);
37
+ for (const c of o.controls)
38
+ lines.push(` ${formatControl(c)}`);
39
+ if (o.messages.length)
40
+ lines.push(`messages: ${o.messages.map((m) => `${m.role} ${q(m.text)}`).join('; ')}`);
41
+ lines.push(o.layout.length
42
+ ? `layout: ${o.layout.length} flag(s)\n${o.layout.map((f) => ` [${f.severity}] ${f.kind}: ${f.message}`).join('\n')}`
43
+ : 'layout: no flags');
44
+ lines.push(`console: ${o.console.errors} errors network: ${o.network.failed} failed requests findings: ${o.findings} in session${o.findings ? ' (inspect for details)' : ''}`);
45
+ return lines.join('\n');
46
+ }
47
+ /** Why a settle timed out, in words (what was still busy and what to tune). */
48
+ export function settleCauseText(cause, pending = []) {
49
+ return cause === 'network'
50
+ ? `request still open: ${pending.join(', ')}; if it is long-lived by design, add it to settle.backgroundRequests`
51
+ : cause === 'busy' ? 'the page kept a visible aria-busy="true" region'
52
+ : cause === 'timers' ? 'short timers kept being scheduled; if that is a polling loop, lower settle.timerMaxMs or set it to 0'
53
+ : cause === 'route' ? 'the address changed to a new route but the page never rendered it (no elements were added or removed)'
54
+ : cause === 'empty' ? 'the page has scripts but nothing was drawn (the app root is still empty); a page that is meant to be blank will always time out here'
55
+ : 'the DOM kept changing';
56
+ }
57
+ export function formatSettle(s) {
58
+ const ignored = s.ignored ? `; ${s.ignored} background request${s.ignored === 1 ? '' : 's'} not awaited` : '';
59
+ if (s.reason === 'quiet')
60
+ return `settled quiet in ${s.ms}ms${ignored}`;
61
+ return `settle timed out after ${s.ms}ms (${settleCauseText(s.cause, s.pending)})${ignored}`;
62
+ }
63
+ export function formatFinding(f, detail = false) {
64
+ const head = `${f.id} [${f.severity}, ${f.confidence}] ${f.kind} @${f.device}: ${f.message}`;
65
+ if (!detail)
66
+ return head;
67
+ const evidence = Object.entries(f.evidence).map(([k, v]) => `${k}=${v}`).join(' ');
68
+ return [
69
+ head,
70
+ ` device ${f.device} (${f.viewportWidth}px) · route ${f.route} · source ${f.source} · seen ${f.occurrences}x (gen ${f.firstSeen.gen}–${f.lastSeenGen})`,
71
+ ` evidence: ${evidence}`,
72
+ ' reproduce:',
73
+ ...f.reproduction.map((step, i) => ` ${i + 1}. ${step}`),
74
+ ].join('\n');
75
+ }
76
+ export function formatInspect(r) {
77
+ const lines = [];
78
+ if (r.control)
79
+ lines.push(`control ${formatControl(r.control)} rect x=${r.control.rect.x} y=${r.control.rect.y} w=${r.control.rect.w} h=${r.control.rect.h}`);
80
+ if (!r.findings.length)
81
+ lines.push(r.control ? 'no findings for this control' : 'no findings in this session');
82
+ const detail = r.findings.length === 1 || !!r.control;
83
+ for (const f of r.findings)
84
+ lines.push(formatFinding(f, detail));
85
+ if (r.omitted)
86
+ lines.push(`${r.omitted} further findings were not recorded (the session keeps at most ${MAX_FINDINGS})`);
87
+ return lines.join('\n');
88
+ }
89
+ export function formatChanges(c) {
90
+ const items = [];
91
+ if (c.tabs) {
92
+ if (c.tabs.opened.length)
93
+ items.push(`+ tab ${c.tabs.opened.join(', ')}`);
94
+ if (c.tabs.closed.length)
95
+ items.push(`- tab ${c.tabs.closed.join(', ')}`);
96
+ if (c.tabs.active)
97
+ items.push(`active tab ${c.tabs.active.from} → ${c.tabs.active.to}`);
98
+ }
99
+ if (c.route)
100
+ items.push(`route ${c.route.from} → ${c.route.to}`);
101
+ if (c.dialog) {
102
+ if (c.dialog.to !== undefined && c.dialog.from === undefined)
103
+ items.push(`+ dialog ${q(c.dialog.to)}`);
104
+ else if (c.dialog.to === undefined)
105
+ items.push(`- dialog ${q(c.dialog.from ?? '')}`);
106
+ else
107
+ items.push(`dialog ${q(c.dialog.from ?? '')} → ${q(c.dialog.to)}`);
108
+ }
109
+ for (const h of c.headingsAdded)
110
+ items.push(`+ ${h}`);
111
+ for (const h of c.headingsRemoved)
112
+ items.push(`- ${h}`);
113
+ for (const a of c.added)
114
+ items.push(`+ ${a.role} ${q(a.name)} ${a.ref}`);
115
+ for (const r of c.removed)
116
+ items.push(`- ${r.role} ${q(r.name)} ${r.ref}`);
117
+ for (const ch of c.changed) {
118
+ const fields = ch.fields.map((f) => typeof f.to === 'boolean' ? (f.to ? f.field : `not ${f.field}`) : f.to === '••••' ? `${f.field} set (hidden: password field)` : `${f.field} ${q(String(f.from ?? ''))}→${q(String(f.to ?? ''))}`);
119
+ items.push(`~ ${ch.role} ${ch.ref} ${fields.join(', ')}`);
120
+ }
121
+ if (c.rerendered.length)
122
+ items.push(`${c.rerendered.length} unchanged controls re-rendered with new refs (${c.rerendered.map((r) => `${r.from}→${r.to}`).join(', ')})`);
123
+ if (c.covered)
124
+ items.push(`${c.covered} background controls now behind the dialog`);
125
+ if (c.uncovered)
126
+ items.push(`${c.uncovered} background controls available again`);
127
+ for (const m of c.messagesAdded)
128
+ items.push(`+ ${m.role} ${q(m.text)}`);
129
+ for (const m of c.messagesRemoved)
130
+ items.push(`- ${m.role} ${q(m.text)}`);
131
+ if (c.focus)
132
+ items.push(`focus ${c.focus.from ?? 'none'} → ${c.focus.to ?? 'none'}`);
133
+ for (const f of c.layoutAdded)
134
+ items.push(`+ layout [${f.severity}] ${f.kind}: ${f.message}`);
135
+ for (const f of c.layoutResolved)
136
+ items.push(`- layout ${f.kind} resolved`);
137
+ if (items.length > MAX_CHANGE_ITEMS) {
138
+ const rest = items.length - MAX_CHANGE_ITEMS;
139
+ return [...items.slice(0, MAX_CHANGE_ITEMS), `… ${rest} more (use --json for all)`];
140
+ }
141
+ return items;
142
+ }
143
+ export function formatAction(r) {
144
+ const target = r.target ? ` ${r.target.role} ${q(r.target.name)}` : '';
145
+ const head = `${r.action}${r.ref ? ` ${r.ref}` : ''}${target}`;
146
+ if (r.outcome === 'error') {
147
+ return [`${head} → ERROR ${formatError(r.error)}`, ...r.notes.map((n) => `note: ${n}`)].join('\n');
148
+ }
149
+ const lines = [`${head} → ok (${r.method}, ${r.elapsedMs}ms, ${r.settle ? formatSettle(r.settle) : 'settle unknown'})`];
150
+ for (const n of r.notes)
151
+ lines.push(`note: ${n}`);
152
+ for (const f of r.newFindings)
153
+ lines.push(`finding: ${formatFinding(f)}`);
154
+ const c = r.changes;
155
+ if (c.reset) {
156
+ const tabs = c.tabs ? formatChanges({ ...c, route: undefined, dialog: undefined, headingsAdded: [], headingsRemoved: [], added: [], removed: [], changed: [], rerendered: [], messagesAdded: [], messagesRemoved: [], layoutAdded: [], layoutResolved: [], focus: undefined, title: undefined, covered: 0, uncovered: 0 }).join('; ') + '; ' : '';
157
+ const why = c.reset.reason === 'tab' ? 'another tab, baseline reset; refs from the previous tab need switch_tab' : 'new document, baseline reset; refs from the previous page are stale';
158
+ lines.push(`changed: ${tabs}${c.route ? `route ${c.route.from} → ${c.route.to}; ` : ''}${why}`);
159
+ lines.push(formatObservation(r.observation));
160
+ }
161
+ else if (c.none) {
162
+ lines.push('changed: no visible change');
163
+ }
164
+ else {
165
+ lines.push(`changed: ${formatChanges(c).join('; ')}`);
166
+ }
167
+ if (!c.reset) {
168
+ const o = r.observation;
169
+ if (c.removed.length)
170
+ 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
+ if (o.layout.length && !c.layoutAdded.length)
172
+ lines.push(`layout: ${o.layout.length} existing flag(s) still present`);
173
+ }
174
+ const errs = r.newConsoleErrors.map((e) => q(e.text)).join('; ');
175
+ 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');
178
+ }
179
+ export function formatStart(r) {
180
+ const s = r.session;
181
+ const d = s.device;
182
+ const srv = r.server;
183
+ const server = srv.owned
184
+ ? `started ${q(srv.command ?? '')} pid ${srv.pid} → ${srv.url} ready in ${srv.readyMs}ms (owned: stopped on close)`
185
+ : `reused healthy server at ${srv.url} (not owned: left running on close)`;
186
+ const services = (r.services ?? []).length > 1 ? r.services.map((x) => ` ${formatService(x)}`) : [];
187
+ return [
188
+ `session ${s.id} device ${d.id} ${d.viewport.width}x${d.viewport.height} @${d.deviceScaleFactor}x${d.hasTouch ? ' touch' : ''} ${s.browser.headed ? 'headed' : 'headless'}${s.auth === 'saved-state' ? ' signed in from saved state' : ''}`,
189
+ `environment: ${s.environment} (emulation, not a real device)`,
190
+ `server: ${server}`,
191
+ ...(services.length ? [`services (in ready order):`, ...services] : []),
192
+ `run dir: ${s.runDir}`,
193
+ formatObservation(r.observation),
194
+ ].join('\n');
195
+ }
196
+ export function formatSweep(r) {
197
+ const byId = new Map(r.findings.map((f) => [f.id, f]));
198
+ const lines = [`sweep ${r.id} of ${r.route}: ${r.devices.length} widths in ${r.ms}ms (serial, isolated contexts)`];
199
+ for (const d of r.devices) {
200
+ if (d.status === 'error') {
201
+ lines.push(` ${d.device} (${d.width}px): ERROR ${d.error?.code}: ${d.error?.message}`);
202
+ continue;
203
+ }
204
+ const fs = d.findings.map((id) => byId.get(id)).filter((f) => !!f);
205
+ const confirmed = fs.filter((f) => f.confidence === 'confirmed');
206
+ lines.push(` ${d.device} (${d.width}px): document ${d.documentWidth}px, ${d.controls} controls, ${d.reachChecked} reach-checked → ` +
207
+ (fs.length ? `${confirmed.length} confirmed, ${fs.length - confirmed.length} heuristic` : 'clean') +
208
+ (d.settled !== 'quiet' ? `; settle timed out (${settleCauseText(d.settleCause ?? 'dom')})` : ''));
209
+ for (const f of fs)
210
+ lines.push(` ${f.id} [${f.severity}, ${f.confidence}] ${f.kind}${f.target ? ` ${f.target.role} ${q(f.target.name)}` : ''}`);
211
+ }
212
+ if (r.interrupted)
213
+ lines.push(` interrupted (${r.interrupted.reason}${r.interrupted.by ? ` by ${r.interrupted.by}` : ''}): not run at ${r.interrupted.skipped.join(', ')}`);
214
+ lines.push(`report: ${r.report} (inspect <id> for measurements and reproduction)`);
215
+ return lines.join('\n');
216
+ }
217
+ export function formatScan(r) {
218
+ const groups = r.groups.filter((g) => !g.suppressed);
219
+ const confirmed = groups.filter((g) => g.confidence === 'confirmed').length;
220
+ const suppressed = r.groups.length - groups.length;
221
+ const failed = r.runs.filter((x) => x.status === 'failed').length;
222
+ const lines = [`scan ${r.id}: ${r.verdict.result.toUpperCase()} · ${r.runs.length} scenario runs (${failed} failed) · ${r.groups.length} problems (${confirmed} confirmed, ${groups.length - confirmed} heuristic, ${suppressed} suppressed) · ${r.ms} ms`];
223
+ for (const reason of r.verdict.reasons)
224
+ lines.push(reason);
225
+ for (const run of r.runs) {
226
+ lines.push(run.status === 'ok'
227
+ ? ` ${run.scenario} @ ${run.device}: ok, ${run.states.length} states, ${run.findings.length} findings`
228
+ : ` ${run.scenario} @ ${run.device}: FAILED at ${run.failedAt ?? 'unknown'}: ${run.error?.message ?? 'no message'}`);
229
+ }
230
+ if (groups.length)
231
+ lines.push('problems (each G groups the findings F that are the same problem at different widths or scenarios; `inspect F1` shows one):');
232
+ for (const g of groups.slice(0, MAX_SCAN_GROUPS))
233
+ lines.push(` ${g.id} ${g.severity} ${g.confidence} ${g.title} [${g.devices.join(', ')}] (${g.findings.join(', ')})`);
234
+ if (groups.length > MAX_SCAN_GROUPS)
235
+ lines.push(` … ${groups.length - MAX_SCAN_GROUPS} more`);
236
+ lines.push(`report: ${r.reports.html}`, `json: ${r.reports.json}`);
237
+ return lines.join('\n');
238
+ }
239
+ export function formatClose(r) {
240
+ const srv = r.server.owned
241
+ ? `server ${r.server.stopped ? 'stopped' : 'NOT stopped'} (${r.server.detail})`
242
+ : `server left running (${r.server.detail})`;
243
+ const others = (r.services ?? []).length > 1
244
+ ? `\nservices: ${r.services.map((x) => `${x.name} ${x.owned ? (x.stopped ? 'stopped' : 'NOT stopped') : 'left running'} (${x.detail})`).join('; ')}`
245
+ : '';
246
+ return `closed (${r.reason}): browser ${r.browserClosed ? 'closed' : 'was already gone'}; ${srv}${others}`;
247
+ }
248
+ export function formatService(s) {
249
+ return s.owned
250
+ ? `${s.name}: started ${q(s.command)}${s.pid ? ` pid ${s.pid}` : ''}${s.url ? ` → ${s.url}` : ''}, ready (${s.readiness}) in ${s.readyMs}ms (owned)`
251
+ : `${s.name}: reused ${s.url ?? s.readiness} (not owned: left running)`;
252
+ }
253
+ export function formatTabs(tabs) {
254
+ return tabs.map((t) => `${t.active ? '*' : ' '} ${t.id} ${t.url}${t.title ? ` ${q(t.title)}` : ''}${t.opener ? ` (opened by ${t.opener})` : ''}`).join('\n');
255
+ }
256
+ export function formatError(e) {
257
+ const lines = [`${e.code}: ${e.message}`];
258
+ if (e.hint)
259
+ lines.push(`hint: ${e.hint}`);
260
+ const tail = e.details?.logTail;
261
+ if (Array.isArray(tail) && tail.length)
262
+ lines.push('server log tail:', ...tail.map((l) => ` ${l}`));
263
+ if (typeof e.details?.logFile === 'string')
264
+ lines.push(`log file: ${e.details.logFile}`);
265
+ const missing = e.details?.missing;
266
+ if (Array.isArray(missing))
267
+ lines.push(...missing.map((m) => ` ${m.service} needs: ${m.names.join(', ')}`));
268
+ const services = e.details?.services;
269
+ if (Array.isArray(services))
270
+ lines.push('services:', ...services.map((s) => ` ${s.name}: ${s.status}${s.detail && s.status !== 'failed' ? ` (${s.detail})` : ''}${s.stopped ? `; stopped (${s.stopped})` : ''}`));
271
+ const options = e.details?.options;
272
+ if (Array.isArray(options))
273
+ lines.push(`options: ${options.map((o) => q(String(o))).join(', ')}`);
274
+ const candidates = e.details?.candidates;
275
+ if (Array.isArray(candidates))
276
+ lines.push(`candidates: ${candidates.map((c) => `${c.ref} ${c.role} ${q(c.name)}`).join('; ')}`);
277
+ return lines.join('\n');
278
+ }
279
+ /** A person's interaction in words, e.g. `tapped button "Save"`, `typed into textbox "Password" (hidden)`. */
280
+ export function formatHuman(a) {
281
+ const t = a.target ? ` ${a.target.role}${a.target.name ? ` ${q(a.target.name)}` : ''}` : '';
282
+ switch (a.type) {
283
+ case 'tap': return `tapped${t}`;
284
+ case 'type': return `typed into${t}${a.secret ? ' (hidden)' : a.chars !== undefined ? ` (${a.chars} chars)` : ''}${a.late ? ' (reported as control returned)' : ''}`;
285
+ case 'key': return `pressed ${a.detail ?? 'a key'}${t ? ` in${t}` : ''}`;
286
+ case 'select': return `chose in${t}${a.detail ? `: ${a.detail}` : ''}`;
287
+ case 'check': return `${a.detail ?? 'toggled'}${t}`;
288
+ case 'upload': return `chose files for${t}${a.detail ? `: ${a.detail}` : ''}`;
289
+ case 'submit': return `submitted${t}`;
290
+ case 'navigate': return `moved the page to ${a.detail ?? '?'}`;
291
+ case 'input-dropped': return a.detail ?? 'more interactions not listed';
292
+ }
293
+ }
294
+ /** A supervision change in words, for the timeline and the daemon log. */
295
+ export function formatControlChange(c) {
296
+ const s = c.state;
297
+ const by = c.by ? ` (${c.by})` : '';
298
+ switch (c.op) {
299
+ case 'pause': return s.mode === 'pausing' ? `pause requested${by}: after ${s.busy?.command ?? 'the current action'} finishes; long commands stop at their next step` : `paused${by}`;
300
+ case 'pause-next': return s.mode === 'pausing' ? `pause requested${by}: before the next action; ${s.busy?.command ?? 'the current command'} runs to the end` : `paused${by}`;
301
+ case 'takeover': return s.mode === 'pausing' ? `takeover requested${by}: after ${s.busy?.command ?? 'the current action'} finishes` : `a person took control of the browser${by}`;
302
+ case 'resume': return `resumed: the agent has control${by}${s.observeRequired ? '; refs from before are stale, observe required' : ''}`;
303
+ case 'return': return `control returned to the agent${by}; refs from before are stale, observe required`;
304
+ case 'stop': return s.mode === 'stopping' ? `stop requested${by}: after ${s.busy?.command ?? 'the current action'} finishes` : stopReason('stop', c.by);
305
+ case 'emergency-stop': return `EMERGENCY STOP${by}: browser closed and owned services stopped now`;
306
+ case 'observed': return 'the agent observed afresh; it may act again';
307
+ case 'settled': return s.mode === 'human' ? 'a person has control of the browser (the action in flight finished)'
308
+ : s.mode === 'paused' ? 'paused (the action in flight finished)' : s.mode === 'stopped' ? 'stopping now (the action in flight finished)' : `control: ${s.mode}`;
309
+ }
310
+ }
311
+ //# sourceMappingURL=format.js.map