debutly 0.0.0-stage → 0.1.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 (89) hide show
  1. package/LICENSE +662 -0
  2. package/README.md +192 -2
  3. package/bin/debutly.mjs +8 -0
  4. package/drizzle/0000_init.sql +107 -0
  5. package/drizzle/0001_predictions.sql +14 -0
  6. package/drizzle/0002_dashboard.sql +15 -0
  7. package/drizzle/meta/0000_snapshot.json +722 -0
  8. package/drizzle/meta/0001_snapshot.json +821 -0
  9. package/drizzle/meta/0002_snapshot.json +942 -0
  10. package/drizzle/meta/_journal.json +27 -0
  11. package/package.json +83 -4
  12. package/src/agent/describe.ts +29 -0
  13. package/src/agent/effect.ts +37 -0
  14. package/src/agent/loop.ts +232 -0
  15. package/src/agent/parse-reply.ts +76 -0
  16. package/src/agent/prompt.ts +76 -0
  17. package/src/agent/run-agent.ts +68 -0
  18. package/src/artifacts/db.ts +55 -0
  19. package/src/artifacts/s3.ts +36 -0
  20. package/src/artifacts/store.ts +26 -0
  21. package/src/bin.ts +140 -0
  22. package/src/browser/ax.ts +77 -0
  23. package/src/browser/execute.ts +338 -0
  24. package/src/browser/observe.ts +56 -0
  25. package/src/browser/reflow.ts +73 -0
  26. package/src/browser/samples.ts +119 -0
  27. package/src/browser/scan-script.ts +62 -0
  28. package/src/browser/settle.ts +32 -0
  29. package/src/browser/signals.ts +73 -0
  30. package/src/cancel.ts +20 -0
  31. package/src/cli.ts +265 -0
  32. package/src/config.ts +62 -0
  33. package/src/cost/estimate.ts +32 -0
  34. package/src/cost/prices.ts +32 -0
  35. package/src/cost/tiers.ts +27 -0
  36. package/src/costs.ts +96 -0
  37. package/src/crew/run-unit.ts +62 -0
  38. package/src/detect/findings.ts +617 -0
  39. package/src/detect/wcag.ts +38 -0
  40. package/src/doctor.ts +145 -0
  41. package/src/hear-cli.ts +137 -0
  42. package/src/hear.ts +309 -0
  43. package/src/import.ts +108 -0
  44. package/src/inbox/mailpit.ts +46 -0
  45. package/src/inbox/types.ts +10 -0
  46. package/src/init.ts +183 -0
  47. package/src/llm/anthropic.ts +41 -0
  48. package/src/llm/credits.ts +35 -0
  49. package/src/llm/factory.ts +65 -0
  50. package/src/llm/meter.ts +39 -0
  51. package/src/llm/openai-compatible.ts +90 -0
  52. package/src/llm/openrouter.ts +9 -0
  53. package/src/llm/scripted.ts +18 -0
  54. package/src/llm/types.ts +36 -0
  55. package/src/perceive.ts +150 -0
  56. package/src/perception/aggregate.ts +111 -0
  57. package/src/perception/compare.ts +77 -0
  58. package/src/perception/evaluate.ts +83 -0
  59. package/src/perception/exhibits.ts +146 -0
  60. package/src/perception/market-context.ts +50 -0
  61. package/src/perception/run-perception.ts +178 -0
  62. package/src/persona/generate.ts +92 -0
  63. package/src/persona/inclusion.ts +53 -0
  64. package/src/persona/language.ts +59 -0
  65. package/src/predictions.ts +33 -0
  66. package/src/queue/dispatch.ts +18 -0
  67. package/src/queue/jobs.ts +28 -0
  68. package/src/queue/processor.ts +42 -0
  69. package/src/report/launch-readiness.ts +106 -0
  70. package/src/report/markdown.ts +200 -0
  71. package/src/run.ts +139 -0
  72. package/src/sandbox/outbox.ts +65 -0
  73. package/src/store/db.ts +40 -0
  74. package/src/store/import-run.ts +116 -0
  75. package/src/store/pg-run-store.ts +163 -0
  76. package/src/store/predictions.ts +70 -0
  77. package/src/store/run-store.ts +78 -0
  78. package/src/store/schema.ts +193 -0
  79. package/src/store/url.ts +15 -0
  80. package/src/types.ts +187 -0
  81. package/src/util/chromium.ts +20 -0
  82. package/src/util/json.ts +7 -0
  83. package/src/util/key-defaults.ts +34 -0
  84. package/src/util/legacy-env.ts +14 -0
  85. package/src/util/load-env.ts +28 -0
  86. package/src/util/pool.ts +12 -0
  87. package/src/util/run-origin.ts +27 -0
  88. package/src/worker.ts +53 -0
  89. package/tsconfig.json +13 -0
@@ -0,0 +1,56 @@
1
+ import type { Page } from "playwright";
2
+ import type { InteractiveElement, Observation } from "../types";
3
+ import { accessibleNames } from "./ax";
4
+ import { SCAN_SCRIPT } from "./scan-script";
5
+
6
+ // The page's language, how much of its visible text is Arabic and the direction that Arabic is
7
+ // laid out in, and whether it lets people zoom. The direction is measured where the Arabic is, as
8
+ // an app may turn its own container right to left rather than <body>: one that did was reported
9
+ // as showing Arabic left to right. A string, like SCAN_SCRIPT: tsx injects helpers into functions
10
+ // the page lacks.
11
+ const LANGUAGE_SCRIPT = `(() => {
12
+ const body = document.body;
13
+ const text = (body && body.innerText) || '';
14
+ const letters = (text.match(/\\p{L}/gu) || []).length;
15
+ const arabic = (text.match(/\\p{Script=Arabic}/gu) || []).length;
16
+ let dir = body ? getComputedStyle(body).direction : 'ltr';
17
+ if (arabic && body) {
18
+ let rtl = 0, ltr = 0;
19
+ const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
20
+ for (let node = walker.nextNode(), seen = 0; node && seen < 5000; node = walker.nextNode(), seen++) {
21
+ const count = (node.nodeValue.match(/\\p{Script=Arabic}/gu) || []).length;
22
+ if (!count || !node.parentElement) continue;
23
+ if (getComputedStyle(node.parentElement).direction === 'rtl') rtl += count;
24
+ else ltr += count;
25
+ }
26
+ if (rtl + ltr) dir = rtl >= ltr ? 'rtl' : 'ltr';
27
+ }
28
+ // Pinch-zoom, which people with low vision rely on on phones (WCAG 1.4.4 asks for 200%).
29
+ const meta = document.querySelector('meta[name="viewport"]');
30
+ const viewport = meta ? meta.getAttribute('content') || '' : '';
31
+ const setting = {};
32
+ for (const part of viewport.split(/[,;]/)) {
33
+ const [key, value] = part.split('=').map((s) => s.trim().toLowerCase());
34
+ if (key) setting[key] = value || '';
35
+ }
36
+ const noScaling = setting['user-scalable'] === 'no' || setting['user-scalable'] === '0';
37
+ const maxScale = parseFloat(setting['maximum-scale']);
38
+ return {
39
+ lang: document.documentElement.getAttribute('lang') || '',
40
+ dir,
41
+ arabicShare: letters ? Math.round((arabic / letters) * 100) / 100 : 0,
42
+ zoomBlocked: noScaling || maxScale < 2 ? viewport : '',
43
+ };
44
+ })()`;
45
+
46
+ export async function observe(page: Page): Promise<Observation> {
47
+ const elements = (await page.evaluate(SCAN_SCRIPT)) as InteractiveElement[];
48
+ const names = await accessibleNames(page);
49
+ if (names) for (const el of elements) {
50
+ const ax = names.get(el.id);
51
+ if (ax) el.ax = ax;
52
+ }
53
+ const text = (await page.locator("body").innerText()).replace(/\s+/g, " ").trim().slice(0, 2000);
54
+ const language = (await page.evaluate(LANGUAGE_SCRIPT)) as Pick<Observation, "lang" | "dir" | "arabicShare" | "zoomBlocked">;
55
+ return { url: page.url(), title: await page.title(), elements, text, ...language };
56
+ }
@@ -0,0 +1,73 @@
1
+ import type { Page } from "playwright";
2
+ import type { ReflowCheck } from "../types";
3
+
4
+ // WCAG 1.4.10 Reflow: at 320 CSS pixels wide (a 1280-pixel screen zoomed to 400%) a page should
5
+ // read without scrolling sideways. Each page the low-vision agent arrives on is measured where it
6
+ // is: its window is narrowed to that width, measured and widened again before the agent looks.
7
+ // Nothing is requested twice, so no link or form is ever replayed to measure it.
8
+
9
+ export const REFLOW_WIDTH = 320;
10
+ const MAX_PAGES = 12;
11
+ /** Time for the page to lay itself out after each change of width. */
12
+ const LAYOUT_MS = 300;
13
+
14
+ // The page's sideways overflow, and the element reaching furthest right outside any scrolling
15
+ // box. A string, like SCAN_SCRIPT: tsx injects helpers into functions the page lacks.
16
+ const MEASURE_SCRIPT = `(() => {
17
+ const doc = document.documentElement;
18
+ const view = doc.clientWidth;
19
+ const overflowPx = Math.max(doc.scrollWidth, document.body ? document.body.scrollWidth : 0) - view;
20
+ if (overflowPx <= 2) return { overflowPx: 0, widest: '', exception: '' };
21
+ const inScroller = (el) => {
22
+ for (let p = el.parentElement; p && p !== document.body && p !== doc; p = p.parentElement) {
23
+ const x = getComputedStyle(p).overflowX;
24
+ if (x !== 'visible') return true;
25
+ }
26
+ return false;
27
+ };
28
+ // Past the right edge, or on a right-to-left page past the left one. (A left-to-right page
29
+ // can't scroll left, so menus parked off its left edge don't count.)
30
+ const rtl = getComputedStyle(document.body).direction === 'rtl';
31
+ let widest = null, furthest = 2;
32
+ for (const el of document.body.querySelectorAll('*')) {
33
+ const r = el.getBoundingClientRect();
34
+ const out = rtl ? -r.left : r.right - view;
35
+ if (r.width > 0 && out > furthest && !inScroller(el)) { widest = el; furthest = out; }
36
+ }
37
+ // WCAG lets content that needs two dimensions scroll: data tables, maps, diagrams, video, code.
38
+ const twoD = widest && widest.closest('table, [role="grid"], [role="table"], pre, canvas, video, iframe, svg, map');
39
+ const name = (el) => {
40
+ const classes = typeof el.className === 'string' ? el.className.trim().split(/\\s+/).filter(Boolean).slice(0, 2) : [];
41
+ const text = (el.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 50);
42
+ return el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + classes.map((c) => '.' + c).join('') + (text ? ' ("' + text + '")' : '');
43
+ };
44
+ return {
45
+ overflowPx: Math.round(overflowPx),
46
+ widest: widest ? name(widest) + ', ' + Math.round(widest.getBoundingClientRect().width) + ' px wide' : '',
47
+ exception: twoD ? 'it is inside ' + (twoD.tagName === 'TABLE' || /grid|table/.test(twoD.getAttribute('role') || '') ? 'a data table' : 'a ' + twoD.tagName.toLowerCase()) + ', which may scroll' : '',
48
+ };
49
+ })()`;
50
+
51
+ export class ReflowMeter {
52
+ private readonly seen = new Set<string>();
53
+ readonly checks: ReflowCheck[] = [];
54
+
55
+ /** Measures the page the agent is on, the first time it arrives at each address. */
56
+ async measure(page: Page): Promise<void> {
57
+ const url = page.url().split("#")[0];
58
+ const size = page.viewportSize();
59
+ if (!size || !/^https?:/.test(url) || this.seen.has(url) || this.seen.size >= MAX_PAGES) return;
60
+ this.seen.add(url);
61
+ try {
62
+ await page.setViewportSize({ width: REFLOW_WIDTH, height: size.height });
63
+ await page.waitForTimeout(LAYOUT_MS);
64
+ const measured = (await page.evaluate(MEASURE_SCRIPT)) as Omit<ReflowCheck, "url" | "widthPx">;
65
+ this.checks.push({ url, widthPx: REFLOW_WIDTH, ...measured });
66
+ } catch {
67
+ // The page changed under the measurement: this one goes without it.
68
+ } finally {
69
+ await page.setViewportSize(size).catch(() => {});
70
+ await page.waitForTimeout(LAYOUT_MS);
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,119 @@
1
+ // Files an agent can attach where an app asks for one: a photo (for a post, an avatar, a logo)
2
+ // and a document (a CV, a brief). Made here rather than shipped, and made up: nothing real or
3
+ // personal is ever uploaded. Written once per process to the system's temp folder.
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { deflateSync } from "node:zlib";
8
+
9
+ export type SampleKind = "photo" | "document";
10
+
11
+ /** A square photo, large enough for social posts that refuse small images (1080 px). */
12
+ const PHOTO_PX = 1080;
13
+
14
+ let written: Promise<Record<SampleKind, string>> | undefined;
15
+
16
+ /** Paths to the sample files, written on first use. */
17
+ export function sampleFiles(): Promise<Record<SampleKind, string>> {
18
+ written ??= (async () => {
19
+ const dir = join(tmpdir(), "debutly-samples");
20
+ await mkdir(dir, { recursive: true });
21
+ const photo = join(dir, "sample-photo.png");
22
+ const document = join(dir, "sample-cv.pdf");
23
+ await writeFile(photo, png(PHOTO_PX, PHOTO_PX));
24
+ await writeFile(document, pdf(SAMPLE_CV));
25
+ return { photo, document };
26
+ })();
27
+ return written;
28
+ }
29
+
30
+ /** Which sample an upload field wants, from its `accept` attribute; a photo when it doesn't say. */
31
+ export function sampleFor(accept: string | null | undefined): SampleKind {
32
+ const a = (accept ?? "").toLowerCase();
33
+ if (!a || /image|\.png|\.jpe?g|\.gif|\.webp/.test(a)) return "photo";
34
+ if (/pdf|word|document|text|\.docx?|\.txt|\.rtf/.test(a)) return "document";
35
+ return "photo";
36
+ }
37
+
38
+ const SAMPLE_CV = [
39
+ "Alex Sample - Curriculum Vitae",
40
+ "alex.sample@example.com - London, UK",
41
+ "",
42
+ "Experience",
43
+ "Marketing Coordinator, Example Ltd (2022 - present)",
44
+ "- Ran weekly social campaigns and reported on their results",
45
+ "Marketing Assistant, Sample & Co (2020 - 2022)",
46
+ "- Wrote newsletters and kept the company blog up to date",
47
+ "",
48
+ "Education",
49
+ "BA Business Studies, Example University (2017 - 2020)",
50
+ "",
51
+ "Skills",
52
+ "Copywriting, Social media, Google Analytics, Canva, Excel",
53
+ ];
54
+
55
+ // A gradient, so image checks that refuse blank pictures accept it.
56
+ function png(width: number, height: number): Buffer {
57
+ const rows: Buffer[] = [];
58
+ for (let y = 0; y < height; y++) {
59
+ const row = Buffer.alloc(1 + width * 3);
60
+ for (let x = 0; x < width; x++) {
61
+ row[1 + x * 3] = Math.round((x / width) * 200) + 30;
62
+ row[2 + x * 3] = Math.round((y / height) * 160) + 60;
63
+ row[3 + x * 3] = 170;
64
+ }
65
+ rows.push(row);
66
+ }
67
+ const header = Buffer.alloc(13);
68
+ header.writeUInt32BE(width, 0);
69
+ header.writeUInt32BE(height, 4);
70
+ header.set([8, 2, 0, 0, 0], 8); // 8-bit RGB
71
+ return Buffer.concat([
72
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
73
+ chunk("IHDR", header),
74
+ chunk("IDAT", deflateSync(Buffer.concat(rows))),
75
+ chunk("IEND", Buffer.alloc(0)),
76
+ ]);
77
+ }
78
+
79
+ function chunk(type: string, data: Buffer): Buffer {
80
+ const length = Buffer.alloc(4);
81
+ length.writeUInt32BE(data.length);
82
+ const body = Buffer.concat([Buffer.from(type, "ascii"), data]);
83
+ const crc = Buffer.alloc(4);
84
+ crc.writeUInt32BE(crc32(body));
85
+ return Buffer.concat([length, body, crc]);
86
+ }
87
+
88
+ function crc32(bytes: Buffer): number {
89
+ let c = ~0;
90
+ for (const b of bytes) {
91
+ c ^= b;
92
+ for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1;
93
+ }
94
+ return ~c >>> 0;
95
+ }
96
+
97
+ // A one-page PDF with real text, so apps that read a CV's text find some.
98
+ function pdf(lines: string[]): Buffer {
99
+ const escape = (s: string) => s.replace(/[\\()]/g, (m) => `\\${m}`);
100
+ const text = ["BT", "/F1 12 Tf", "16 TL", "56 780 Td", ...lines.map((l) => `(${escape(l)}) '`), "ET"].join("\n");
101
+ const objects = [
102
+ "<< /Type /Catalog /Pages 2 0 R >>",
103
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
104
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
105
+ `<< /Length ${Buffer.byteLength(text)} >>\nstream\n${text}\nendstream`,
106
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
107
+ ];
108
+ let out = "%PDF-1.4\n";
109
+ const offsets: number[] = [];
110
+ objects.forEach((body, i) => {
111
+ offsets.push(Buffer.byteLength(out));
112
+ out += `${i + 1} 0 obj\n${body}\nendobj\n`;
113
+ });
114
+ const xref = Buffer.byteLength(out);
115
+ out += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
116
+ out += offsets.map((o) => `${String(o).padStart(10, "0")} 00000 n \n`).join("");
117
+ out += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
118
+ return Buffer.from(out, "latin1");
119
+ }
@@ -0,0 +1,62 @@
1
+ // Runs in the page. Passed as a string, not a function, because tsx/esbuild can inject
2
+ // helpers (e.g. __name) into function bodies that don't exist inside the browser.
3
+ // Includes ARIA widgets (custom selects, menus, tabs, toggles) that apps build from divs:
4
+ // without them, agents can open a dropdown but never see its options.
5
+ export const SCAN_SCRIPT = `(() => {
6
+ const selector = [
7
+ 'a[href]', 'button', 'input', 'select', 'textarea', 'summary',
8
+ '[role="button"]', '[role="link"]', '[role="option"]', '[role="menuitem"]', '[role="menuitemcheckbox"]',
9
+ '[role="menuitemradio"]', '[role="tab"]', '[role="checkbox"]', '[role="radio"]', '[role="switch"]', '[role="combobox"]',
10
+ ].join(', ');
11
+ const visible = (el) => typeof el.checkVisibility === 'function'
12
+ ? el.checkVisibility({ visibilityProperty: true })
13
+ : (getComputedStyle(el).display !== 'none' && getComputedStyle(el).visibility !== 'hidden');
14
+ const textOf = (el) => (el && (el.innerText || el.textContent)) || '';
15
+ const out = [];
16
+ let next = 0;
17
+ // Ids from the last look stay on elements that have since gone out of sight; the same number
18
+ // given to another element then matches two ("strict mode violation"). Start clean each time.
19
+ for (const el of document.querySelectorAll('[data-debutly-id]')) el.removeAttribute('data-debutly-id');
20
+ for (const el of document.querySelectorAll(selector)) {
21
+ // Apps hide the real file field behind a styled button or drop zone; files can still be
22
+ // attached to it, so list it.
23
+ const fileField = el.tagName === 'INPUT' && el.type === 'file';
24
+ if (el.type === 'hidden' || (!visible(el) && !fileField)) continue;
25
+ const id = next++;
26
+ el.setAttribute('data-debutly-id', String(id));
27
+ const tag = el.tagName.toLowerCase();
28
+ const role = el.getAttribute('role') || (tag === 'a' ? 'link' : tag === 'input' ? 'input:' + (el.type || 'text') : tag);
29
+ const labelledBy = (el.getAttribute('aria-labelledby') || '').split(/\\s+/).filter(Boolean)
30
+ .map((ref) => textOf(document.getElementById(ref))).join(' ');
31
+ // A field's label points at it (for=) or wraps it; a wrapping label's name is its own text,
32
+ // without the field's (a select's options, a textarea's content).
33
+ const field = tag === 'input' || tag === 'select' || tag === 'textarea';
34
+ const labelEl = (field && el.labels && el.labels[0]) || (el.id ? document.querySelector('label[for="' + CSS.escape(el.id) + '"]') : null);
35
+ let label = null;
36
+ if (labelEl) {
37
+ label = labelEl.cloneNode(true);
38
+ label.querySelectorAll('input, select, textarea, button').forEach((n) => n.remove());
39
+ }
40
+ // title is the accessible name of last resort, as browsers and screen readers treat it.
41
+ // A native select's text is all its options run together: name it by its label, and list them apart.
42
+ const isSelect = tag === 'select';
43
+ const fileHint = fileField ? 'file upload' + (el.getAttribute('accept') ? ' (' + el.getAttribute('accept') + ')' : '') : '';
44
+ const labelled = el.getAttribute('aria-label') || labelledBy || (label && label.textContent.trim());
45
+ const placeholder = el.getAttribute('placeholder');
46
+ const raw = labelled || placeholder || (isSelect ? '' : textOf(el).trim()) || el.getAttribute('title') || fileHint;
47
+ const item = { id, role, name: raw.replace(/\\s+/g, ' ').trim().slice(0, 80), tag };
48
+ // A placeholder is an example, not a label: screen readers read it as the field's name (a
49
+ // password's dots as "13 bullet"), and it's gone once someone types.
50
+ if (!labelled && placeholder) item.nameFrom = 'placeholder';
51
+ // What a keyboard user can reach: a disabled control, or one out of the tab order, isn't.
52
+ if (el.disabled === true || el.getAttribute('aria-disabled') === 'true') item.disabled = true;
53
+ else if (el.tabIndex < 0 || el.closest('[inert]')) item.tabbable = false;
54
+ if (isSelect) {
55
+ const chosen = el.selectedOptions[0];
56
+ item.value = chosen ? chosen.label.slice(0, 80) : '';
57
+ item.options = Array.from(el.options).slice(0, 30).map((o) => o.label.slice(0, 60));
58
+ }
59
+ out.push(item);
60
+ }
61
+ return out;
62
+ })()`;
@@ -0,0 +1,32 @@
1
+ import type { Page } from "playwright";
2
+
3
+ export interface SettleOptions {
4
+ /** Longest wait for the network to go quiet (ms). */
5
+ idleMs?: number;
6
+ /** Longest extra wait while the page still says it is loading (ms). */
7
+ busyMs?: number;
8
+ }
9
+
10
+ /**
11
+ * Waits, as a person would, until the page has finished loading before it is read: the network
12
+ * goes quiet, and no "Loading…" placeholder or aria-busy region is left. Client-rendered apps show
13
+ * a spinner or an empty shell at DOMContentLoaded; on a slow connection agents otherwise judge (and
14
+ * navigate away from) pages that were only still loading. Bounded, so a page that never settles
15
+ * (polling, a stuck spinner) is read as it is.
16
+ */
17
+ export async function settle(page: Page, o: SettleOptions = {}): Promise<void> {
18
+ const { idleMs = 5_000, busyMs = 10_000 } = o;
19
+ await page.waitForLoadState("networkidle", { timeout: idleMs }).catch(() => {});
20
+ const deadline = Date.now() + busyMs;
21
+ while (Date.now() < deadline) {
22
+ const busy = await page
23
+ .evaluate(() => {
24
+ if (document.querySelector('[aria-busy="true"]')) return true;
25
+ const text = (document.body?.innerText ?? "").trim();
26
+ return text.length < 300 && /\b(loading|please wait)\b/i.test(text);
27
+ })
28
+ .catch(() => false);
29
+ if (!busy) return;
30
+ await page.waitForTimeout(500);
31
+ }
32
+ }
@@ -0,0 +1,73 @@
1
+ import type { Page } from "playwright";
2
+ import type { Signal } from "../types";
3
+
4
+ const BODY_CHARS = 500;
5
+ /** How long a step waits for server-error bodies still being read before it is stored without them. */
6
+ const BODY_WAIT_MS = 2000;
7
+
8
+ export class SignalCollector {
9
+ private buffer: Signal[] = [];
10
+ private reading = new Set<Promise<void>>();
11
+
12
+ constructor(page: Page) {
13
+ page.on("console", (msg) => {
14
+ if (msg.type() === "error") this.buffer.push({ kind: "console_error", message: msg.text(), url: page.url() });
15
+ });
16
+ page.on("pageerror", (err) => {
17
+ this.buffer.push({ kind: "page_error", message: err.message, url: page.url() });
18
+ });
19
+ page.on("response", (res) => {
20
+ if (res.status() < 400) return;
21
+ // A failed navigation belongs to the page that was requested, not the one being left.
22
+ const url = res.request().isNavigationRequest() ? res.url() : page.url();
23
+ const startedAt = res.request().timing().startTime;
24
+ const signal: Signal = { kind: "http_error", status: res.status(), requestUrl: res.url(), url, startedAt: startedAt > 0 ? startedAt : undefined, at: Date.now() };
25
+ this.buffer.push(signal);
26
+ // A server error often says why (e.g. "No API key found"). Read the start of it; it lands on
27
+ // the same signal object, which findings read once the run is over.
28
+ if (res.status() >= 500) {
29
+ const read = res
30
+ .text()
31
+ .then((text) => {
32
+ if (signal.kind === "http_error") signal.body = text.slice(0, BODY_CHARS);
33
+ })
34
+ .catch(() => {})
35
+ .finally(() => this.reading.delete(read));
36
+ this.reading.add(read);
37
+ }
38
+ });
39
+ // Generating a report often only downloads a file; without this the agent saw "nothing
40
+ // visibly changed" and concluded the button was broken.
41
+ page.on("download", (download) => {
42
+ this.buffer.push({ kind: "download", filename: download.suggestedFilename(), url: page.url() });
43
+ });
44
+ // Playwright dismisses dialogs by default, so every confirm() would read as Cancel. Answer OK,
45
+ // as a person going along with the app would, and let the agent know what it asked.
46
+ page.on("dialog", (dialog) => {
47
+ this.buffer.push({ kind: "dialog", dialogType: dialog.type(), message: dialog.message(), url: page.url() });
48
+ dialog.accept().catch(() => {});
49
+ });
50
+ }
51
+
52
+ /** A signal the agent loop observed itself, such as a slow page. */
53
+ note(signal: Signal): void {
54
+ this.buffer.push(signal);
55
+ }
56
+
57
+ /**
58
+ * Waits, briefly, for the server-error bodies still being read. A step is stored as soon as it
59
+ * ends; a fast 502 was stored without the body that said why it failed (2026-10-17).
60
+ */
61
+ async settle(): Promise<void> {
62
+ if (!this.reading.size) return;
63
+ let timer: ReturnType<typeof setTimeout> | undefined;
64
+ await Promise.race([Promise.allSettled([...this.reading]), new Promise((r) => (timer = setTimeout(r, BODY_WAIT_MS)))]);
65
+ clearTimeout(timer);
66
+ }
67
+
68
+ drain(): Signal[] {
69
+ const out = this.buffer;
70
+ this.buffer = [];
71
+ return out;
72
+ }
73
+ }
package/src/cancel.ts ADDED
@@ -0,0 +1,20 @@
1
+ // The run kill switch from anywhere: npm run debutly:cancel -- <runId> --store postgres://…
2
+ // Agents stop at their next step; the run still writes its (partial) report.
3
+ import { parseArgs } from "node:util";
4
+ import { openDb } from "./store/db";
5
+ import { PgRunStore } from "./store/pg-run-store";
6
+
7
+ const { values, positionals } = parseArgs({ allowPositionals: true, options: { store: { type: "string" } } });
8
+ const [runId] = positionals;
9
+ const url = values.store ?? process.env.DATABASE_URL;
10
+ if (!runId || !url) {
11
+ console.error("Usage: npm run debutly:cancel -- <runId> --store postgres://… (or set DATABASE_URL)");
12
+ process.exit(1);
13
+ }
14
+ const { db, close } = await openDb(url);
15
+ const store = new PgRunStore(db);
16
+ await store.requestCancel(runId);
17
+ const run = await store.getRun(runId);
18
+ await close();
19
+ console.log(run ? `Run ${runId}: ${run.status}` : `No run ${runId}`);
20
+ process.exit(run ? 0 : 1);