@tea-agent/loop-agent 0.44.0-next.12 → 0.44.0-next.13

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 (122) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/commands/init.js +1 -0
  4. package/dist/executors/dag-pi-executor.js +11 -6
  5. package/dist/executors/pi-writer-tool-policy.js +60 -4
  6. package/dist/executors/playwright-cli-launcher.js +31 -45
  7. package/dist/executors/shell-write-guard.js +24 -67
  8. package/dist/governance/exec-plans.js +10 -1
  9. package/dist/task/config-types.js +4 -2
  10. package/dist/task/source-prepare/build-draft.js +1 -0
  11. package/dist/task/source-prepare/path-policy.js +17 -7
  12. package/dist/worker/console/chat/browser-automation.js +343 -38
  13. package/dist/worker/console/chat/browser-content.js +74 -0
  14. package/dist/worker/console/chat/browser-diagnostics.js +250 -0
  15. package/dist/worker/console/chat/browser-evaluation.js +58 -0
  16. package/dist/worker/console/chat/browser-operation-queue.js +19 -0
  17. package/dist/worker/console/chat/browser-pick.js +113 -0
  18. package/dist/worker/console/chat/browser-routes.js +33 -5
  19. package/dist/worker/console/chat/browser-screenshot-result.js +47 -0
  20. package/dist/worker/console/chat/browser-storage.js +89 -0
  21. package/dist/worker/console/chat/pi-runtime/custom-tools/browser-tools.js +125 -174
  22. package/dist/worker/console/chat/pi-runtime.js +1 -1
  23. package/dist/worker/console/chat/repo-browser.js +80 -2
  24. package/dist/worker/console/chat/repo-preview-kind.js +155 -0
  25. package/dist/worker/console/chat/session-mode.js +1 -1
  26. package/dist/worker/console/chat/session-store.js +4 -2
  27. package/dist/worker/console/chat/tools.js +12 -0
  28. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-r1NAPU_5.js → abnfDiagram-N423BO3Z-BA7qCL_r.js} +1 -1
  29. package/dist/worker/console/static/assets/{arc-dT5T4sVL.js → arc-Df09ajwL.js} +1 -1
  30. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CP_cOq46.js → architectureDiagram-T3A2C74G-CpF9sw5R.js} +1 -1
  31. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-sToEZxEf.js → blockDiagram-VBNYF7ZC-BtPys8ja.js} +1 -1
  32. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DSxwwi2m.js → c4Diagram-5PPSVZJV-DZPnTWqT.js} +1 -1
  33. package/dist/worker/console/static/assets/channel-DiTNLZUm.js +1 -0
  34. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-B9fh0IkF.js → chunk-2GRJ4B5K-BD9D4h1p.js} +1 -1
  35. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-CAUNl-A6.js → chunk-2Q5K7J3B-Svn8yBmn.js} +1 -1
  36. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-BomeqbNX.js → chunk-5RXB4S5H-B8EbQj-S.js} +1 -1
  37. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BNAuTOLE.js → chunk-5VM5RSS4-tENZpcpF.js} +1 -1
  38. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DfzjRD5y.js → chunk-6Q2QTUOP-DBx71Lco.js} +1 -1
  39. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-C3uE-yc2.js → chunk-GF5L2VYU-1v_QamMz.js} +1 -1
  40. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-C3sQMMYr.js → chunk-JWPE2WC7-BAMCNNBG.js} +1 -1
  41. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-D_AtEYQL.js → chunk-KBJHAD2P-BvuEury-.js} +1 -1
  42. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DW4TwhKv.js → chunk-RYQCIY6F-YqFuHp8s.js} +1 -1
  43. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CTQDmJcp.js → chunk-XXDRQBXY-ouI5T7Mr.js} +1 -1
  44. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgL8aiyZ.js +1 -0
  45. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgL8aiyZ.js +1 -0
  46. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-iMqPuBsL.js → cose-bilkent-JH36ORCC-Bri0_Izk.js} +1 -1
  47. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DktIuDWs.js → cynefin-VYW2F7L2-e8Vhhi4X.js} +1 -1
  48. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BgVnjyEs.js → cynefinDiagram-MW4NZA55-tSIXGQiJ.js} +1 -1
  49. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CR25E4bu.js → dagre-VZM6K2ZE-Rl-sF5pt.js} +1 -1
  50. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CPM2h5HY.js → diagram-7IWD3JNH-Db6ddPz8.js} +1 -1
  51. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DqF0x2g1.js → diagram-B4RE2ZJO-Jwd_Xw5S.js} +1 -1
  52. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-G8BYp2eb.js → diagram-LBJQPF4R-CeC_8InC.js} +1 -1
  53. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BbC4kT_D.js → diagram-Q27KOJAE-LKPRaNq8.js} +1 -1
  54. package/dist/worker/console/static/assets/{diagram-UB23O5K3-CKZwZ-G5.js → diagram-UB23O5K3-DlVQytMW.js} +1 -1
  55. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-kJDmheia.js → ebnfDiagram-BXEA7PRR-DnfJZwO-.js} +1 -1
  56. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-DFTVHyxB.js → erDiagram-JOGREHBK-3axVSXca.js} +1 -1
  57. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BqdN5_os.js → flowDiagram-UKHOOZJN-xe04-_L-.js} +1 -1
  58. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-SYDjVakh.js → ganttDiagram-PKOTCBZU-hpmRINiD.js} +1 -1
  59. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js → gitGraphDiagram-DS77QQ5N-6HSNz9Ko.js} +1 -1
  60. package/dist/worker/console/static/assets/index-C_8_gWal.css +1 -0
  61. package/dist/worker/console/static/assets/{index-CTMZsDmh.js → index-DybcVXCu.js} +102 -100
  62. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DIeAmJ45.js → infoDiagram-6WML65LV-BF6ksWTX.js} +1 -1
  63. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-oikqUAlo.js → ishikawaDiagram-WSZJBQD7-DXisdeD7.js} +1 -1
  64. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-mKEF_xYT.js → journeyDiagram-NVQOT4AX-BWu65wmu.js} +1 -1
  65. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-BdWJHPnN.js → kanban-definition-27J2QSJJ-hfNhHqAI.js} +1 -1
  66. package/dist/worker/console/static/assets/{linear-DGRlieMH.js → linear-D6HTfQcp.js} +1 -1
  67. package/dist/worker/console/static/assets/{mermaid.core-4P9yPC9c.js → mermaid.core-Iv91R1WP.js} +5 -5
  68. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-5PTSkos6.js → mindmap-definition-FAOFIHXS-CfPChwjR.js} +1 -1
  69. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DONIFpG5.js → pegDiagram-VL7TDLO6-B7TcoqHz.js} +1 -1
  70. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-D_Z4S70M.js → pieDiagram-7S7Q4E2Y-ouyPKMtQ.js} +1 -1
  71. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CAsjh8iY.js → quadrantDiagram-CIZ2JOQS-B9tuyLbh.js} +1 -1
  72. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Cx4Dd6ac.js → railroadDiagram-AXF67PYL-Ck4q9nJs.js} +1 -1
  73. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-cQb7mwyH.js → requirementDiagram-LRYGKXZP-wXLX5Dnp.js} +1 -1
  74. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cd-DhUdf.js → sankeyDiagram-W5VNT64P-D2XWw6_m.js} +1 -1
  75. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-AlKYRgIk.js → sequenceDiagram-SI44F4Z6-DzxKiug4.js} +1 -1
  76. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CUy_ET4e.js → sizeCapture-X5ZJPWSS-DWUqK5Us.js} +1 -1
  77. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CTuVe6Sa.js → stateDiagram-OKZ733FA-D5Pa4O5g.js} +1 -1
  78. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C0QIbqO4.js +1 -0
  79. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-ChFLJb-v.js → swimlanes-SLNWSIFB-VH7cRnsJ.js} +2 -2
  80. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-BB9CzRHB.js +8 -0
  81. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-DZZeiAPr.js → timeline-definition-Z64GVDOM-C4HM5NoZ.js} +1 -1
  82. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-t4O91cZU.js → vennDiagram-T6HMQDX7-Bu-4hOlA.js} +1 -1
  83. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-CV5qGHZu.js → wardleyDiagram-T6FBY63Y-CasFEvvl.js} +1 -1
  84. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-DyXbytUI.js → xychartDiagram-ELKLHX3M-D-gpwPSA.js} +1 -1
  85. package/dist/worker/console/static/index.html +2 -2
  86. package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +57 -9
  87. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +6 -0
  88. package/dist/worker/console/workspace-context.js +1 -0
  89. package/dist/worker/materialize/harness-task-lineage.js +4 -2
  90. package/dist/worker/observe/static/console-theme.css +33 -29
  91. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +3 -1
  92. package/dist/workflows/dag/backend-test-layout.js +27 -7
  93. package/dist/workflows/dag/backend-test-markdown-workflow.js +3 -1
  94. package/dist/workflows/dag/frontend-test-case-checklist.js +12 -3
  95. package/dist/workflows/dag/frontend-test-environment-probe.js +0 -2
  96. package/dist/workflows/dag/frontend-test-layout.js +41 -12
  97. package/dist/workflows/dag/hybrid/sources.js +21 -2
  98. package/dist/workflows/dag/hybrid/templates/backend-test.js +65 -50
  99. package/dist/workflows/dag/hybrid/templates/frontend-test.js +58 -37
  100. package/dist/workflows/dag/test-artifact-namespace.js +73 -0
  101. package/dist/workflows/dag/validate.js +22 -3
  102. package/docs/templates/README.md +4 -4
  103. package/docs/templates/backend-test-dag.generate-pytest.prompt.md +3 -3
  104. package/docs/templates/backend-test-dag.json +708 -708
  105. package/docs/templates/backend-test-dag.review-cases.prompt.md +3 -3
  106. package/docs/templates/frontend-test-case-checklist.md +2 -2
  107. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +7 -7
  108. package/docs/templates/frontend-test-dag.json +46 -37
  109. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +2 -2
  110. package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
  111. package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
  112. package/package.json +7 -2
  113. package/skills/fe-test-ui-scout/SKILL.md +4 -4
  114. package/skills/fe-test-ui-scout/references/ledger-schema.md +1 -1
  115. package/skills/playwright-cli/SKILL.md +1 -1
  116. package/skills/playwright-cli-case-generator/SKILL.md +4 -4
  117. package/dist/worker/console/static/assets/channel-CyBY_yGk.js +0 -1
  118. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +0 -1
  119. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +0 -1
  120. package/dist/worker/console/static/assets/index-B28Onmfy.css +0 -1
  121. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +0 -1
  122. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +0 -8
@@ -0,0 +1,74 @@
1
+ import { Readability } from "@mozilla/readability";
2
+ import { parseHTML } from "linkedom";
3
+ import TurndownService from "turndown";
4
+ /** Clone the current DOM and materialize live form properties without changing the page. */
5
+ export async function captureBrowserHtml(locator) {
6
+ return locator.evaluate((root) => {
7
+ const clone = root.cloneNode(true);
8
+ const originalFields = [root, ...root.querySelectorAll("input,textarea,select")];
9
+ const clonedFields = [clone, ...clone.querySelectorAll("input,textarea,select")];
10
+ originalFields.forEach((field, index) => {
11
+ const copy = clonedFields[index];
12
+ if (!copy)
13
+ return;
14
+ if (field instanceof HTMLInputElement) {
15
+ copy.setAttribute("value", field.type === "password" ? "" : field.value);
16
+ copy.toggleAttribute("checked", field.checked);
17
+ }
18
+ else if (field instanceof HTMLTextAreaElement)
19
+ copy.textContent = field.value;
20
+ else if (field instanceof HTMLSelectElement) {
21
+ Array.from(copy.querySelectorAll("option")).forEach((option, optionIndex) => option.toggleAttribute("selected", field.options[optionIndex]?.selected ?? false));
22
+ }
23
+ });
24
+ return clone.outerHTML;
25
+ });
26
+ }
27
+ export function browserHtmlToMarkdown(html, url, mode = "page") {
28
+ if (html.length > 1_000_000)
29
+ throw new Error("CONTENT_TOO_LARGE: request a smaller region/ref (HTML limit 1M characters)");
30
+ const { document } = parseHTML(`<!doctype html><html><body>${html}</body></html>`);
31
+ document.querySelectorAll("head,script,style,noscript,[hidden],input[type=hidden]").forEach((node) => node.remove());
32
+ for (const field of document.querySelectorAll("input,textarea,select")) {
33
+ const type = field.getAttribute("type");
34
+ const value = type === "password" ? "[password omitted]"
35
+ : type === "checkbox" || type === "radio" ? (field.hasAttribute("checked") ? "[x]" : "[ ]")
36
+ : field.localName === "select" ? Array.from(field.querySelectorAll("option[selected]"), (option) => option.textContent).join(", ")
37
+ : field.getAttribute("value") ?? field.textContent ?? "";
38
+ const replacement = document.createElement("span");
39
+ if (type === "checkbox" || type === "radio")
40
+ replacement.setAttribute("data-loop-checked", field.hasAttribute("checked") ? "true" : "false");
41
+ replacement.textContent = `${field.getAttribute("aria-label") ?? field.getAttribute("name") ?? ""}: ${value} `;
42
+ field.replaceWith(replacement);
43
+ }
44
+ for (const node of document.querySelectorAll("a[href],img[src]")) {
45
+ const attr = node.localName === "a" ? "href" : "src";
46
+ try {
47
+ node.setAttribute(attr, new URL(node.getAttribute(attr) ?? "", url).href);
48
+ }
49
+ catch {
50
+ node.removeAttribute(attr);
51
+ }
52
+ }
53
+ const article = mode === "article" ? new Readability(document.cloneNode(true)).parse() : null;
54
+ const source = article?.content || (mode === "article" ? document.querySelector("main,article,[role=main]")?.innerHTML : undefined) || document.body.innerHTML;
55
+ const converter = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
56
+ converter.addRule("checkboxState", {
57
+ filter: (node) => node.nodeName === "SPAN" && node.hasAttribute("data-loop-checked"),
58
+ replacement: (_content, node) => `\n- [${node.getAttribute("data-loop-checked") === "true" ? "x" : " "}] ${converter.escape((node.textContent ?? "").split(":")[0] ?? "")}\n`,
59
+ });
60
+ converter.addRule("tables", {
61
+ filter: "table",
62
+ replacement: (_content, node) => {
63
+ const rows = Array.from(node.querySelectorAll("tr"), (row) => Array.from(row.children, (cell) => (cell.textContent ?? "").trim().replace(/\|/g, "\\|").replace(/\s+/g, " ")));
64
+ const width = Math.max(0, ...rows.map((row) => row.length));
65
+ if (!width)
66
+ return "";
67
+ const header = rows.shift() ?? [];
68
+ const format = (row) => `| ${Array.from({ length: width }, (_, index) => row[index] ?? "").join(" | ")} |`;
69
+ return `\n\n${[format(header), format(Array(width).fill("---")), ...rows.map(format)].join("\n")}\n\n`;
70
+ },
71
+ });
72
+ const markdown = converter.turndown(source);
73
+ return { markdown: markdown.slice(0, 24_000), truncated: markdown.length > 24_000, mode, articleExtracted: !!article?.content };
74
+ }
@@ -0,0 +1,250 @@
1
+ /** Metadata only: never retain credentials, query values, fragments or non-HTTP payload URLs. */
2
+ export function diagnosticUrl(raw) {
3
+ try {
4
+ const url = new URL(raw);
5
+ if (url.protocol !== "http:" && url.protocol !== "https:")
6
+ return `[${url.protocol} URL omitted]`;
7
+ url.username = "";
8
+ url.password = "";
9
+ url.hash = "";
10
+ // Omit keys too: applications sometimes put secrets in parameter names.
11
+ const query = url.search ? "?[redacted]" : "";
12
+ url.search = "";
13
+ return `${url.href}${query}`.slice(0, 2000);
14
+ }
15
+ catch {
16
+ return "[invalid URL omitted]";
17
+ }
18
+ }
19
+ /** Best-effort redaction, not a guarantee for arbitrary application log text. */
20
+ export function diagnosticText(raw) {
21
+ const text = raw
22
+ .replace(/https?:\/\/[^\s<>"']+/gi, (url) => diagnosticUrl(url))
23
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9+/_=.\-]+/gi, "$1 [redacted]")
24
+ .replace(/\b(token|password|passwd|secret|api[_-]?key|authorization|cookie)\b(["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi, "$1$2[redacted]");
25
+ return text.length > 2000 ? `${text.slice(0, 2000)}… [truncated]` : text;
26
+ }
27
+ /** Append-only journal. Clearing and eviction do not reset its cursor. */
28
+ export class BrowserDiagnosticJournal {
29
+ capacity;
30
+ entries = [];
31
+ lastSequence = 0;
32
+ removedThrough = 0;
33
+ constructor(capacity) {
34
+ this.capacity = capacity;
35
+ if (!Number.isSafeInteger(capacity) || capacity < 1)
36
+ throw new Error("invalid journal capacity");
37
+ }
38
+ append(entry, sequence) {
39
+ this.lastSequence = sequence;
40
+ this.entries.push({ ...entry, sequence });
41
+ while (this.entries.length > this.capacity) {
42
+ const removed = this.entries.shift();
43
+ if (removed)
44
+ this.removedThrough = removed.sequence;
45
+ }
46
+ }
47
+ read(query, tabId) {
48
+ const after = query.after ?? 0;
49
+ const limit = query.limit ?? 50;
50
+ if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
51
+ throw new Error("after must be a non-negative integer and limit must be 1..100");
52
+ }
53
+ const pending = this.entries.filter((entry) => entry.sequence > after);
54
+ const entries = pending.slice(0, limit);
55
+ const nextCursor = entries.at(-1)?.sequence ?? Math.max(after, this.lastSequence);
56
+ const batch = { tabId, entries, nextCursor, hasMore: pending.length > limit, gap: after < this.removedThrough };
57
+ if (query.clear) {
58
+ this.entries = this.entries.filter((entry) => entry.sequence > nextCursor);
59
+ this.removedThrough = Math.max(this.removedThrough, Math.min(nextCursor, this.lastSequence));
60
+ }
61
+ return batch;
62
+ }
63
+ }
64
+ /** Owned by one browser service; no disk persistence and no request/response bodies. */
65
+ export class BrowserDiagnostics {
66
+ tabs = new Map();
67
+ pending = new Map();
68
+ nextSequence = 1;
69
+ nextRequestId = 1;
70
+ detachContext;
71
+ resolveTab;
72
+ trackPage(page, tabId) {
73
+ if (this.tabs.has(tabId)) {
74
+ this.flushUnattributed();
75
+ return;
76
+ }
77
+ const journal = new BrowserDiagnosticJournal(200);
78
+ const onConsole = (message) => {
79
+ const location = message.location();
80
+ journal.append({
81
+ timestamp: Date.now(), level: message.type(), text: diagnosticText(message.text()),
82
+ location: { url: diagnosticUrl(location.url), line: location.lineNumber, column: location.columnNumber },
83
+ }, this.nextSequence++);
84
+ };
85
+ const onError = (error) => {
86
+ journal.append({ timestamp: Date.now(), level: "pageerror", text: diagnosticText(error.message) }, this.nextSequence++);
87
+ };
88
+ page.on("console", onConsole);
89
+ page.on("pageerror", onError);
90
+ this.tabs.set(tabId, {
91
+ console: journal,
92
+ network: new BrowserDiagnosticJournal(300),
93
+ detach: () => { page.off("console", onConsole); page.off("pageerror", onError); },
94
+ });
95
+ this.flushUnattributed();
96
+ }
97
+ attachContext(context, trackPage) {
98
+ this.detachContext?.();
99
+ this.resolveTab = trackPage;
100
+ const onRequest = (request) => {
101
+ if (request.serviceWorker?.())
102
+ return;
103
+ const startedAt = Date.now();
104
+ const pending = {
105
+ tabId: null, deferred: [], startedAt, completed: false,
106
+ entry: {
107
+ requestId: this.nextRequestId++, url: diagnosticUrl(request.url()),
108
+ method: request.method().slice(0, 32), resourceType: request.resourceType(),
109
+ },
110
+ };
111
+ this.pending.set(request, pending);
112
+ this.flushUnattributed();
113
+ this.record(pending, "request");
114
+ // Bound retained Playwright Request objects as well as output journals.
115
+ while (this.pending.size > 500) {
116
+ const oldest = this.pending.keys().next().value;
117
+ if (!oldest)
118
+ break;
119
+ const dropped = this.pending.get(oldest);
120
+ if (dropped)
121
+ this.record(dropped, "observation-lost", { failure: "observation-capacity-exceeded" });
122
+ this.pending.delete(oldest);
123
+ }
124
+ };
125
+ const onResponse = (response) => {
126
+ this.flushUnattributed();
127
+ const pending = this.pending.get(response.request());
128
+ if (pending) {
129
+ pending.entry.status = response.status();
130
+ this.record(pending, "response");
131
+ }
132
+ };
133
+ const complete = (request, event) => {
134
+ this.flushUnattributed();
135
+ this.complete(request, event);
136
+ };
137
+ const onFinished = (request) => complete(request, "finished");
138
+ const onFailed = (request) => complete(request, "failed");
139
+ context.on("request", onRequest);
140
+ context.on("response", onResponse);
141
+ context.on("requestfinished", onFinished);
142
+ context.on("requestfailed", onFailed);
143
+ this.detachContext = () => {
144
+ context.off("request", onRequest);
145
+ context.off("response", onResponse);
146
+ context.off("requestfinished", onFinished);
147
+ context.off("requestfailed", onFailed);
148
+ };
149
+ }
150
+ console(query, tabId) {
151
+ return (this.tabs.get(tabId ?? -1)?.console ?? new BrowserDiagnosticJournal(1)).read(query, tabId);
152
+ }
153
+ network(query, tabId) {
154
+ return (this.tabs.get(tabId ?? -1)?.network ?? new BrowserDiagnosticJournal(1)).read(query, tabId);
155
+ }
156
+ removePage(tabId) {
157
+ this.tabs.get(tabId)?.detach();
158
+ this.tabs.delete(tabId);
159
+ for (const [request, pending] of this.pending) {
160
+ if (pending.tabId === tabId)
161
+ this.pending.delete(request);
162
+ }
163
+ }
164
+ reset() {
165
+ this.detachContext?.();
166
+ this.detachContext = undefined;
167
+ this.resolveTab = undefined;
168
+ for (const tabId of this.tabs.keys())
169
+ this.removePage(tabId);
170
+ this.pending.clear();
171
+ }
172
+ flushUnattributed() {
173
+ if (!this.resolveTab)
174
+ return;
175
+ for (const [request, pending] of this.pending) {
176
+ this.attributeRequest(request, pending);
177
+ }
178
+ for (const [request, pending] of this.pending) {
179
+ if (pending.tabId === null)
180
+ continue;
181
+ this.attributeRedirectChain(request, pending.tabId);
182
+ if (pending.completed)
183
+ this.pending.delete(request);
184
+ }
185
+ }
186
+ attributeRequest(request, pending) {
187
+ if (pending.tabId !== null || !this.resolveTab)
188
+ return;
189
+ try {
190
+ const page = request.frame().page();
191
+ if (page.isClosed())
192
+ return;
193
+ this.assignTab(pending, this.resolveTab(page));
194
+ }
195
+ catch {
196
+ // Popup navigation can precede frame creation. Retry on later events
197
+ // or when a redirect successor can be attributed; never guess a tab.
198
+ }
199
+ }
200
+ attributeRedirectChain(request, tabId) {
201
+ const neighbor = (item, direction) => {
202
+ const read = item[direction];
203
+ if (typeof read !== "function")
204
+ return null;
205
+ return read.call(item) ?? null;
206
+ };
207
+ let current = neighbor(request, "redirectedFrom");
208
+ while (current) {
209
+ const previous = this.pending.get(current);
210
+ if (previous)
211
+ this.assignTab(previous, tabId);
212
+ current = neighbor(current, "redirectedFrom");
213
+ }
214
+ current = neighbor(request, "redirectedTo");
215
+ while (current) {
216
+ const next = this.pending.get(current);
217
+ if (next)
218
+ this.assignTab(next, tabId);
219
+ current = neighbor(current, "redirectedTo");
220
+ }
221
+ }
222
+ assignTab(pending, tabId) {
223
+ if (pending.tabId === tabId)
224
+ return;
225
+ pending.tabId = tabId;
226
+ const journal = this.tabs.get(tabId)?.network;
227
+ for (const entry of pending.deferred)
228
+ journal?.append(entry, this.nextSequence++);
229
+ pending.deferred = [];
230
+ }
231
+ record(pending, event, extra) {
232
+ const timestamp = Date.now();
233
+ const entry = { ...pending.entry, timestamp, event, durationMs: Math.max(0, timestamp - pending.startedAt), ...extra };
234
+ if (pending.tabId === null)
235
+ pending.deferred.push(entry);
236
+ else
237
+ this.tabs.get(pending.tabId)?.network.append(entry, this.nextSequence++);
238
+ }
239
+ complete(request, event) {
240
+ const pending = this.pending.get(request);
241
+ if (!pending)
242
+ return;
243
+ this.record(pending, event, event === "failed"
244
+ ? { failure: diagnosticText(request.failure()?.errorText ?? "request failed") }
245
+ : undefined);
246
+ pending.completed = true;
247
+ if (pending.tabId !== null)
248
+ this.pending.delete(request);
249
+ }
250
+ }
@@ -0,0 +1,58 @@
1
+ /** Executes an expression in the page, returning a JSON-safe, size-bounded envelope. */
2
+ export async function evaluateBrowserExpression(page, expression, maxChars = 24_000) {
3
+ const result = await page.evaluate(async ({ expression, maxChars }) => {
4
+ const AsyncFunction = Object.getPrototypeOf(async () => { }).constructor;
5
+ const value = await new AsyncFunction(`return (${expression}\n)`)();
6
+ const seen = new WeakSet();
7
+ const json = JSON.stringify(value, (_key, item) => {
8
+ if (item === undefined)
9
+ return { type: "undefined" };
10
+ if (typeof item === "bigint")
11
+ return { type: "bigint", value: String(item) };
12
+ if (typeof item === "number" && !Number.isFinite(item))
13
+ return { type: "number", value: String(item) };
14
+ if (typeof item === "function" || typeof item === "symbol")
15
+ return { type: typeof item, value: String(item) };
16
+ if (item && typeof item === "object") {
17
+ if (seen.has(item))
18
+ return { type: "circular" };
19
+ seen.add(item);
20
+ }
21
+ return item;
22
+ });
23
+ return { json: json.slice(0, maxChars), truncated: json.length > maxChars };
24
+ }, { expression, maxChars });
25
+ if (result.truncated)
26
+ return { text: result.json, truncated: true };
27
+ try {
28
+ return { value: JSON.parse(result.json), truncated: false };
29
+ }
30
+ catch (cause) {
31
+ throw new Error("page returned invalid serialized evaluation result", { cause });
32
+ }
33
+ }
34
+ /** Stops waiting without claiming the underlying browser operation was cancelled. */
35
+ export async function waitForBrowserOperation(pending, timeoutMs, signal) {
36
+ let timer;
37
+ let abort;
38
+ try {
39
+ return await Promise.race([
40
+ pending,
41
+ new Promise((_resolve, reject) => {
42
+ abort = () => reject(Object.assign(new Error("browser operation cancelled; page work may still be running"), { code: "BROWSER_CANCELLED" }));
43
+ if (signal?.aborted) {
44
+ abort();
45
+ return;
46
+ }
47
+ signal?.addEventListener("abort", abort, { once: true });
48
+ timer = setTimeout(() => reject(Object.assign(new Error("page evaluation timed out; wait for recovery or explicitly close the tab"), { code: "EVAL_TIMEOUT" })), timeoutMs);
49
+ }),
50
+ ]);
51
+ }
52
+ finally {
53
+ if (timer)
54
+ clearTimeout(timer);
55
+ if (abort)
56
+ signal?.removeEventListener("abort", abort);
57
+ }
58
+ }
@@ -0,0 +1,19 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ /** Serializes service entrypoints, including HTTP and chat, without reentrant deadlocks. */
3
+ export class BrowserOperationQueue {
4
+ tail = Promise.resolve();
5
+ local = new AsyncLocalStorage();
6
+ get current() { return this.local.getStore(); }
7
+ run(target, signal, operation) {
8
+ const task = this.tail.then(async () => {
9
+ signal?.throwIfAborted();
10
+ return this.local.run({ target, signal }, async () => {
11
+ const result = await operation();
12
+ signal?.throwIfAborted();
13
+ return result;
14
+ });
15
+ });
16
+ this.tail = task.catch(() => undefined);
17
+ return task;
18
+ }
19
+ }
@@ -0,0 +1,113 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { waitForBrowserOperation } from "./browser-evaluation.js";
3
+ /** Human input happens in the external window, never the independent sidebar iframe. */
4
+ export async function pickBrowserElement(page, message, timeoutMs, signal) {
5
+ const key = `__loopPick_${randomUUID().replace(/-/g, "")}`;
6
+ const pending = page.evaluateHandle(({ key, message, timeoutMs }) => new Promise((resolve) => {
7
+ const global = window;
8
+ const banner = document.createElement("div");
9
+ banner.dataset.loopBrowserPicker = "true";
10
+ banner.textContent = `${message} — click a control; Esc to cancel`;
11
+ banner.style.cssText = "position:fixed;bottom:16px;left:16px;padding:12px;background:#172033;color:white;z-index:2147483647;font:14px sans-serif;pointer-events:none";
12
+ document.documentElement.append(banner);
13
+ const frames = Array.from(document.querySelectorAll("iframe,frame"), (frame) => {
14
+ const cover = document.createElement("div");
15
+ cover.textContent = "Frame picking unavailable";
16
+ cover.style.cssText = "position:fixed;z-index:2147483646;background:#17203322;color:#172033;font:12px sans-serif";
17
+ document.documentElement.append(cover);
18
+ return { frame, cover };
19
+ });
20
+ const positionFrames = () => {
21
+ for (const { frame, cover } of frames) {
22
+ const rect = frame.getBoundingClientRect();
23
+ Object.assign(cover.style, { left: `${rect.x}px`, top: `${rect.y}px`, width: `${rect.width}px`, height: `${rect.height}px` });
24
+ }
25
+ };
26
+ positionFrames();
27
+ window.addEventListener("scroll", positionFrames, true);
28
+ window.addEventListener("resize", positionFrames);
29
+ const finish = (value) => {
30
+ clearTimeout(timer);
31
+ document.removeEventListener("click", click, true);
32
+ document.removeEventListener("keydown", keydown, true);
33
+ banner.remove();
34
+ frames.forEach(({ cover }) => cover.remove());
35
+ window.removeEventListener("scroll", positionFrames, true);
36
+ window.removeEventListener("resize", positionFrames);
37
+ delete global[key];
38
+ resolve(value);
39
+ };
40
+ const click = (event) => {
41
+ event.preventDefault();
42
+ event.stopImmediatePropagation();
43
+ const element = event.composedPath()[0];
44
+ if (!(element instanceof Element))
45
+ return;
46
+ if (frames.some(({ cover }) => cover === element)) {
47
+ finish({ code: "PICK_UNSUPPORTED" });
48
+ return;
49
+ }
50
+ finish({ element: element.closest("button,a,input,select,textarea,[role=button],[role=checkbox]") ?? element });
51
+ };
52
+ const keydown = (event) => {
53
+ if (event.key !== "Escape")
54
+ return;
55
+ event.preventDefault();
56
+ event.stopImmediatePropagation();
57
+ finish({ code: "PICK_CANCELLED" });
58
+ };
59
+ const timer = setTimeout(() => finish({ code: "PICK_TIMEOUT" }), timeoutMs);
60
+ global[key] = () => finish({ code: "PICK_CANCELLED" });
61
+ document.addEventListener("click", click, true);
62
+ document.addEventListener("keydown", keydown, true);
63
+ }), { key, message, timeoutMs });
64
+ try {
65
+ const handle = await waitForBrowserOperation(pending, timeoutMs + 1000, signal);
66
+ const codeHandle = await handle.getProperty("code");
67
+ const code = await codeHandle.jsonValue();
68
+ await codeHandle.dispose();
69
+ if (code) {
70
+ await handle.dispose();
71
+ throw Object.assign(new Error(String(code)), { code });
72
+ }
73
+ const element = (await handle.getProperty("element")).asElement();
74
+ await handle.dispose();
75
+ if (!element)
76
+ throw Object.assign(new Error("PICK_UNSUPPORTED: no selectable element"), { code: "PICK_UNSUPPORTED" });
77
+ return element;
78
+ }
79
+ catch (error) {
80
+ if (browserDocumentGone(error)) {
81
+ throw Object.assign(new Error("page navigated or closed during picking; inspect again"), { code: "PICK_NAVIGATED" });
82
+ }
83
+ throw error;
84
+ }
85
+ finally {
86
+ let cleanupFailure;
87
+ const cleanup = page.evaluate((key) => {
88
+ const cancel = window[key];
89
+ if (typeof cancel === "function")
90
+ cancel();
91
+ }, key).catch((error) => { cleanupFailure = error; });
92
+ // A cancelled caller must not leave an eventual JS handle undisposed.
93
+ if (signal?.aborted)
94
+ void pending.then((handle) => handle.dispose()).catch(() => undefined);
95
+ let confirmed = true;
96
+ try {
97
+ await waitForBrowserOperation(cleanup, 1000);
98
+ }
99
+ catch {
100
+ confirmed = false;
101
+ }
102
+ // A navigation replaces the document (overlay is gone); any other unconfirmed
103
+ // cleanup leaves unknown page state and must quarantine the tab.
104
+ if (!confirmed || (cleanupFailure !== undefined && !browserDocumentGone(cleanupFailure))) {
105
+ throw Object.assign(new Error("picker cleanup unconfirmed; explicitly close this tab to recover"), { code: "PAGE_BUSY" });
106
+ }
107
+ }
108
+ }
109
+ /** True when the old document is provably gone, so a failed overlay cleanup is safe. */
110
+ export function browserDocumentGone(error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return /context was destroyed|frame was detached|has been closed|Target closed|browser has been closed/i.test(message);
113
+ }
@@ -1,7 +1,6 @@
1
1
  /**
2
- * HTTP API for the Console Browser panel. The panel renders screenshots from
3
- * the controlled browser and sends pointer/keyboard input back to the same
4
- * Playwright page used by Operator Chat tools.
2
+ * HTTP API for the workspace-controlled external browser. The sidebar iframe
3
+ * is an independent preview; these endpoints operate the Playwright page.
5
4
  */
6
5
  import { embeddabilityOf, extractFrameAncestors, normalizeBrowserUrl, } from "./browser-policy.js";
7
6
  function workspaceIdOf(req) {
@@ -49,7 +48,15 @@ async function readJson(req) {
49
48
  }
50
49
  if (chunks.length === 0)
51
50
  return {};
52
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
51
+ try {
52
+ const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
53
+ if (!value || typeof value !== "object" || Array.isArray(value))
54
+ throw new Error("expected JSON object");
55
+ return value;
56
+ }
57
+ catch (cause) {
58
+ throw new Error("invalid browser request JSON object", { cause });
59
+ }
53
60
  }
54
61
  export async function handleBrowserRequest(req, res, deps, pathname) {
55
62
  if (!pathname.startsWith("/api/operator/v1/chat/browser"))
@@ -59,6 +66,26 @@ export async function handleBrowserRequest(req, res, deps, pathname) {
59
66
  const parts = rest.split("/").filter(Boolean);
60
67
  const sub = parts[0] ?? "";
61
68
  try {
69
+ if (method === "GET" && sub === "screenshots" && parts.length === 2) {
70
+ const scope = requireScope(req, deps);
71
+ if (!scope.ok) {
72
+ sendJson(res, scope.status, { ok: false, error: { code: scope.code, message: scope.message } });
73
+ return true;
74
+ }
75
+ if (!deps.automation) {
76
+ sendJson(res, 503, { ok: false });
77
+ return true;
78
+ }
79
+ try {
80
+ const bytes = await deps.automation.readScreenshot(parts[1] ?? "");
81
+ res.writeHead(200, { "content-type": "image/png", "cache-control": "no-store", "x-content-type-options": "nosniff" });
82
+ res.end(bytes);
83
+ }
84
+ catch {
85
+ sendJson(res, 404, { ok: false, error: { code: "SCREENSHOT_UNAVAILABLE", message: "screenshot unavailable" } });
86
+ }
87
+ return true;
88
+ }
62
89
  if (method === "GET" && sub === "state" && parts.length === 1) {
63
90
  const scope = requireScope(req, deps);
64
91
  if (!scope.ok) {
@@ -160,7 +187,8 @@ export async function handleBrowserRequest(req, res, deps, pathname) {
160
187
  return true;
161
188
  }
162
189
  const body = await readJson(req);
163
- const result = await dispatchBrowserPost(deps.automation, sub, body);
190
+ const automation = deps.automation;
191
+ const result = await automation.run({ tabId: numberOrUndefined(body.tabId) }, () => dispatchBrowserPost(automation, sub, body));
164
192
  sendJson(res, 200, { ok: true, data: result });
165
193
  return true;
166
194
  }
@@ -0,0 +1,47 @@
1
+ /** Small text envelope survives live SSE and restored tool-result previews. */
2
+ const SCREENSHOT_METADATA_BUDGET = 2500;
3
+ const ESSENTIAL_KEYS = ["tabId", "snapshotId", "screenshotUrl", "screenshotPath", "screenshotError", "imageWarning"];
4
+ const OPTIONAL_KEYS = ["title", "url"];
5
+ const OPTIONAL_KEY_SET = new Set(OPTIONAL_KEYS);
6
+ /** Control characters balloon the serialized JSON and can break history restore. */
7
+ function cleanString(value, max) {
8
+ if (typeof value !== "string")
9
+ return value;
10
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").slice(0, max);
11
+ }
12
+ export function screenshotMetadata(result) {
13
+ const output = {};
14
+ for (const key of [...ESSENTIAL_KEYS, ...OPTIONAL_KEYS]) {
15
+ if (result[key] !== undefined)
16
+ output[key] = cleanString(result[key], OPTIONAL_KEY_SET.has(key) ? 200 : 500);
17
+ }
18
+ // Never truncate the serialized JSON itself: shed optional fields, then shorten the rest.
19
+ if (JSON.stringify(output).length > SCREENSHOT_METADATA_BUDGET)
20
+ for (const key of OPTIONAL_KEYS)
21
+ delete output[key];
22
+ if (JSON.stringify(output).length > SCREENSHOT_METADATA_BUDGET) {
23
+ for (const key of ESSENTIAL_KEYS)
24
+ if (typeof output[key] === "string")
25
+ output[key] = output[key].slice(0, 120);
26
+ }
27
+ return output;
28
+ }
29
+ /** Only renderer-local, workspace-scoped screenshot routes may become an <img>. */
30
+ export function screenshotUrlFromText(text) {
31
+ try {
32
+ const data = JSON.parse(text);
33
+ if (!data || typeof data !== "object" || !("screenshotUrl" in data) || typeof data.screenshotUrl !== "string")
34
+ return undefined;
35
+ return /^\/api\/operator\/v1\/chat\/browser\/screenshots\/[0-9a-f-]{36}\.png\?workspaceId=[A-Za-z0-9_%.-]+$/.test(data.screenshotUrl) ? data.screenshotUrl : undefined;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ /** Console's text history omits binary payloads; this never mutates Pi model messages. */
42
+ export function omitBrowserImageData(_key, value) {
43
+ if (value && typeof value === "object" && "type" in value && value.type === "image") {
44
+ return { type: "image", dataOmitted: true };
45
+ }
46
+ return value;
47
+ }